Skip to content

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.

Terminal window
dotnet add package Zautha.AspNetCore

Keep the keys out of source control, e.g. with user secrets during development:

Terminal window
dotnet user-secrets init
dotnet user-secrets set "Zautha:PublishableKey" "pk_test_…"
dotnet user-secrets set "Zautha:SecretKey" "sk_test_…"
dotnet user-secrets set "Zautha:WebhookSigningSecret" "whsec_…"
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" });
},
});

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.

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.

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).

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.
  • UpdateAsync sends 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.
  • RemoveMfaAsync removes 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, Passkeys and BackupCodesRemaining show where a user stands. ZauthaUser.ExternalAccounts lists the Google, GitHub or other provider accounts they sign in with.
  • Every error response throws ZauthaApiException; branch on Code (resource_not_found, form_identifier_exists, too_many_requests with RetryAfter, …) and quote TraceId to 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 an idempotencyKey to CreateAsync when you use them.
  • Requests time out after 10 seconds; AddZauthaClient returns the IHttpClientBuilder for your own handlers and timeout.

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.