Zautha.AspNetCore
Zautha for ASP.NET Core (.NET 10): authenticate your API’s requests by the Zautha session token
your frontend sends (@zautha/js, @zautha/react), and manage your users and sessions through the Backend API.
Install
Section titled “Install”dotnet add package Zautha.AspNetCoreKeep the keys out of source control, e.g. with user secrets during development:
dotnet user-secrets initdotnet user-secrets set "Zautha:PublishableKey" "pk_test_…"dotnet user-secrets set "Zautha:SecretKey" "sk_test_…"dotnet user-secrets set "Zautha:WebhookSigningSecret" "whsec_…"Authenticate requests
Section titled “Authenticate requests”using System.Security.Claims;using Zautha.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddZauthaAuthentication(options =>{ options.PublishableKey = builder.Configuration["Zautha:PublishableKey"]; options.AuthorizedParties = ["https://app.example.com"];});builder.Services.AddAuthorization();
var app = builder.Build();app.UseAuthentication();app.UseAuthorization();
app.MapGet("/api/whoami", (ClaimsPrincipal user) => user.GetZauthaAuth().UserId).RequireAuthorization();
app.Run();The frontend sends the token as Authorization: Bearer <token> (await zautha.session.getToken()). A request is
authenticated only when the token
- is signed (RS256) by one of your instance’s keys, read from its JWKS and cached for an hour (an unknown key id triggers a refetch, at most every five minutes),
- was issued by your instance’s Frontend API (from the publishable key) and has not expired (5 s tolerance),
- was minted for one of
AuthorizedParties, the exact origins your frontends are served from.
Anything else gets 401 with WWW-Authenticate: Bearer; the reason goes to the log, never to the response. The
options are checked when the app starts: a malformed publishable key or an empty AuthorizedParties stops it.
user.GetZauthaAuth() returns the user id (user_…), session id (sess_…), the authorized party and the minutes since
the last first- and second-factor verification. It throws when the request was not authenticated by Zautha — call it
only in endpoints that require authorization. The token’s claims also stay on the principal under their JWT names
(user.FindFirst("azp")?.Value; see the session token).
The JWKS request never follows a redirect. To route it through your own handler (a proxy, a test server), set the
scheme’s BackchannelHttpHandler to an HttpClientHandler or a SocketsHttpHandler: the package turns its redirects
off. Any other handler type, or your own Backchannel HttpClient, stops the app at startup.
To verify without any network request, set options.JwtKey to one of your instance’s public signing keys as an RSA
public key in PEM (-----BEGIN PUBLIC KEY-----), e.g. made from a key of the instance’s JWKS (GetJwksAsync() below).
The scheme (ZauthaDefaults.AuthenticationScheme, "Zautha") is a JwtBearer scheme; its events are yours, e.g. a JSON
body for 401:
using Microsoft.AspNetCore.Authentication.JwtBearer;
builder.Services.Configure<JwtBearerOptions>(ZauthaDefaults.AuthenticationScheme, options => options.Events = new JwtBearerEvents { OnChallenge = async context => { context.HandleResponse(); context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.Headers.WWWAuthenticate = "Bearer"; await context.Response.WriteAsJsonAsync(new { code = "unauthenticated" }); }, });Reverification
Section titled “Reverification”For a sensitive operation (changing a payout account, deleting data), ask for a recent identity check instead of just a signed-in user:
app.MapPost("/api/transfer", () => Results.Ok(new { ok = true })) .RequireAuthorization() .RequireZauthaReverification(ZauthaReverification.Strict);RequireZauthaReverification lets the request through only when the user verified their identity recently enough:
| Requirement | Window | User with two-step verification | User without it |
|---|---|---|---|
ZauthaReverification.Strict |
10 minutes | second factor | first factor |
ZauthaReverification.Moderate |
1 hour | second factor | first factor |
ZauthaReverification.Lax |
1 day | second factor | first factor |
ZauthaReverification.StrictMfa |
10 minutes | both factors | first factor |
The first factor is the password, an email code or a link; the second factor is an authenticator app code or a backup
code. ZauthaReverification.Within(TimeSpan.FromMinutes(30), ZauthaReverificationLevel.FirstFactor) sets both yourself
(whole minutes from 1 to 1440; the level is SecondFactor when left out, and FirstFactor always asks for the first
factor). Otherwise it answers 403 with an application/problem+json body whose code is reverification_required
and whose reverification member says what was asked for ({"level":"second_factor","after_minutes":10}). On the
frontend, useReverification (@zautha/react) recognizes that response, asks the user to verify again (with the factors
that level needs) and retries the request with a fresh token. The filter works on route groups too; a request that is
not authenticated by Zautha is challenged (401), so keep RequireAuthorization().
In your own code, user.GetZauthaAuth().IsReverified(ZauthaReverification.Strict) answers the same question. The session
token counts whole minutes since each verification (FactorVerificationAge; the second is -1 for a user without
two-step verification), so a 10-minute window passes a token that says 9 and not one that says 10. A token can be up to
about a minute old, so the decision can lag Zautha’s own by about a minute.
Impersonation
Section titled “Impersonation”In an impersonation session user.GetZauthaAuth().Actor is a ZauthaActor(Sub, Iss) from the token’s act claim:
Sub is the id your server gave when it created the actor token, or the dashboard member’s (user_…); Iss is the
dashboard’s origin for one started there, else null. It is null for the user’s own session. A token whose act is
malformed is not accepted. Such a session never passes IsReverified/RequireZauthaReverification
(FactorVerificationAge is -1, -1; there -1 does not mean “no second factor”). ZauthaSession.Actor marks
impersonation sessions in IZauthaClient session lists and in session webhook payloads.
Start an impersonation from your server (Pro) with the Backend API client (below):
var token = await zautha.ActorTokens.CreateAsync(new CreateActorTokenRequest{ UserId = "user_…", Actor = new ActorTokenActor { Sub = agentId }, RedirectUrl = "https://app.example.com/",}, ct);// Open token.Url in the agent's browser: one use, valid 10 minutes by default (ExpiresInSeconds, 60–3600); the// session lasts 30 minutes by default (SessionMaxDurationInSeconds, 60–7200).ActorTokens.GetAsync(id) reads one (Pending, Accepted with its SessionId, or Revoked) and
ActorTokens.RevokeAsync(id) cancels it while it is unused (actor_token_not_pending afterwards). Only the
CreateAsync response carries the Url (left out of its ToString()); it takes no idempotency key.
Organizations: roles and permissions
Section titled “Organizations: roles and permissions”When the session has an active organization, user.GetZauthaAuth().Organization carries it from the session token:
Id (org_…), Slug (null without one), Role (org:admin) and Permissions (the role’s custom permissions,
org:invoices:read); it is null without an active organization. HasRole(role) and HasPermission(permission) take
full keys and answer from the token alone, the same as every Zautha SDK; a system permission (org:sys_…) is never in
the token, so check a role or a custom permission instead. A token whose o claim is malformed fails authentication.
app.MapGet("/api/invoices", () => …).RequireAuthorization().RequireZauthaPermission("org:invoices:read");app.MapDelete("/api/projects/{id}", (string id) => …).RequireAuthorization().RequireZauthaRole("org:admin");A request whose token lacks the role or permission gets 403 application/problem+json with code forbidden; one
not authenticated by Zautha is challenged (401), so keep RequireAuthorization(). Role and permission changes reach
the token at its next refresh (at most a minute).
Backend API
Section titled “Backend API”builder.Services.AddZauthaClient(options =>{ options.SecretKey = builder.Configuration["Zautha:SecretKey"];});
app.MapGet("/api/me", async (ClaimsPrincipal user, IZauthaClient zautha, CancellationToken ct) =>{ var me = await zautha.Users.GetAsync(user.GetZauthaAuth().UserId, ct); // Only what the browser may see: private metadata stays on the server. return Results.Ok(new { id = me.Id, primary_email_address = me.PrimaryEmailAddress, first_name = me.FirstName, last_name = me.LastName });}).RequireAuthorization();IZauthaClient covers users (Users.ListAsync, GetAsync, CreateAsync, UpdateAsync, DeleteAsync, BanAsync,
UnbanAsync, RemoveMfaAsync, ListSessionsAsync), sessions (Sessions.RevokeAsync, RevokeAllAsync), organizations
(Organizations.ListAsync, GetAsync, CreateAsync, UpdateAsync, DeleteAsync, UpdateMetadataAsync; an id, or a
slug while the instance has slugs on), memberships (OrganizationMemberships.ListAsync, CreateAsync, UpdateAsync,
DeleteAsync, UpdateMetadataAsync, ListForUserAsync), invitations (OrganizationInvitations.CreateAsync,
CreateBulkAsync, ListAsync, GetAsync, RevokeAsync; only the create responses carry the invitation link, Url,
which you send yourself with Notify = false), roles and permissions (OrganizationRoles,
OrganizationPermissions: ListAsync, GetAsync, CreateAsync, UpdateAsync, DeleteAsync; keys never change),
impersonation (ActorTokens.CreateAsync, GetAsync, RevokeAsync; see Impersonation) and the
instance’s public keys (GetJwksAsync).
CreateAsync(request, idempotencyKey): retrying with the same key within 24 hours returns the first response instead of creating a second user.UpdateAsyncsends only the properties you assign:new UpdateUserRequest { FirstName = null }clears the first name, other fields stay as they are, and assigned metadata replaces the whole object.RemoveMfaAsyncremoves a user’s two-step verification (authenticator app, passkeys and backup codes) when they lost their devices, and ends all their sessions;ZauthaUser.MfaEnabled,TotpEnabled,PasskeysandBackupCodesRemainingshow where a user stands.ZauthaUser.ExternalAccountslists the Google, GitHub or other provider accounts they sign in with.- Every error response throws
ZauthaApiException; branch onCode(resource_not_found,form_identifier_exists,too_many_requestswithRetryAfter, …) and quoteTraceIdto support. The client does not retry by itself, but app-wide resilience handlers (e.g.ConfigureHttpClientDefaults(b => b.AddStandardResilienceHandler())in Aspire’s ServiceDefaults) apply to it too: pass anidempotencyKeytoCreateAsyncwhen you use them. - Requests time out after 10 seconds;
AddZauthaClientreturns theIHttpClientBuilderfor your own handlers and timeout.
Webhooks
Section titled “Webhooks”Each webhook endpoint in the dashboard has a signing secret (whsec_…):
builder.Services.AddZauthaWebhooks(options =>{ options.SigningSecret = builder.Configuration["Zautha:WebhookSigningSecret"];});
app.MapPost("/webhooks/zautha", async (HttpRequest request, IZauthaWebhookVerifier webhooks) =>{ ZauthaWebhookEvent webhook; try { webhook = await webhooks.VerifyAsync(request); } catch (ZauthaWebhookVerificationException) { return Results.BadRequest(); }
switch (webhook.Type) { case ZauthaWebhookEventTypes.UserCreated: var user = webhook.AsUser(); // … break; case ZauthaWebhookEventTypes.UserDeleted: var deletedId = webhook.AsDeletedObject().Id; // … break; }
return Results.NoContent();});VerifyAsync reads the body itself: don’t bind it to a parameter, and call it before anything else reads the body.
It checks the Standard Webhooks signature and that webhook-timestamp is at most 5 minutes from your clock;
ZauthaWebhookVerificationException.Error says why a request was refused. Data is the event’s object as the Backend
API returns it, as it was when the event happened: AsUser() for user.created/user.updated, AsSession() for
session.created/session.ended/session.revoked, AsOrganization() for organization.created/organization.updated,
AsOrganizationMembership() for organizationMembership.created/updated/deleted (a deleted membership as it was),
AsOrganizationInvitation() for organizationInvitation.created/accepted/revoked, AsDeletedObject() for user.deleted and organization.deleted. Answer with a 2xx quickly:
anything else, or no answer within 15 seconds, is retried for up to 5 days. A message keeps its webhook-id across
retries and resends, so use it to skip duplicates.