@zautha/backend
Server SDK for Zautha: verify session tokens and find out who is behind a request, on
any JavaScript runtime (Node, Bun, Deno, edge). Built on Web standard Request/Response; Node and Express
helpers live in their own entry points.
Install
Section titled “Install”npm i @zautha/backendVerify a session token
Section titled “Verify a session token”import { verifyToken, isTokenVerificationError } from "@zautha/backend";
try { const claims = await verifyToken(token, { publishableKey: process.env.ZAUTHA_PUBLISHABLE_KEY!, authorizedParties: ["https://app.example.com"], }); console.log(claims.sub, claims.sid);} catch (error) { if (isTokenVerificationError(error)) console.log(error.code); // token_expired, token_invalid, … else throw error;}Keys come from your instance’s JWKS and are cached in memory for an hour. Pass jwtKey (the instance’s
public key in PEM) to verify without any network request. authorizedParties is required: list the origins
your app is served from.
Authenticate a request
Section titled “Authenticate a request”import { authenticateRequest } from "@zautha/backend";
const state = await authenticateRequest(request, { publishableKey: process.env.ZAUTHA_PUBLISHABLE_KEY!, authorizedParties: ["https://app.example.com"],});
if (state.status === "handshake") return new Response(null, { status: 307, headers: state.headers });// Apply state.headers (cookies, cache headers) to your response in every case.if (state.status === "signed-in") console.log(state.auth.userId);It accepts an Authorization: Bearer token or the __Host-zautha_session cookie written by @zautha/js
(__zautha_session_{suffix} on a development instance). When the cookie
has expired, production page loads briefly redirect through your instance to get a fresh one (handshake);
development instances refresh it server to server. The handshake is bound to the browser that started it by a
short-lived HttpOnly state cookie: a returning link without the matching state (someone else’s link, or a
browser that refuses cookies) renders the page signed out (handshake_state_invalid) and never redirects again.
Express
Section titled “Express”import express from "express";import { zauthaMiddleware, requireAuth, getAuth } from "@zautha/backend/express";
const app = express();app.use(zauthaMiddleware({ publishableKey, authorizedParties: ["https://app.example.com"] }));app.get("/api/me", requireAuth(), (req, res) => { const auth = getAuth(req); res.json(auth.status === "signed-in" ? { userId: auth.auth.userId } : null);});requireAuth() answers signed-out requests with 401 problem details.
Sensitive operations (reverification)
Section titled “Sensitive operations (reverification)”requireReverification(requirement) lets a request through only when the user verified their identity recently
enough. For a user with two-step verification, "strict" (10 minutes), "moderate" (1 hour) and "lax" (1 day) ask
for the second factor (an authenticator app or backup code) and "strict_mfa" (10 minutes) for both factors; a user
without it verifies the first factor (password or email code) at every level. { afterMinutes, level? } sets both
(1–1440 minutes; level is "first_factor", "second_factor" (the default) or "multi_factor"; anything else
throws). Otherwise it answers 403 reverification_required naming the level, which useReverification
(@zautha/react) handles by asking the user to verify it again and retrying. Signed-out requests get 401 as with
requireAuth().
import { requireReverification } from "@zautha/backend/express";
app.post("/api/transfer", requireReverification("strict"), (req, res) => res.json({ ok: true }));Anywhere else, a signed-in auth has has({ reverification }), and reverificationError(requirement) builds the
403 Response:
import { authenticateRequest, reverificationError } from "@zautha/backend";
const state = await authenticateRequest(request, options);if (state.status !== "signed-in") return new Response(null, { status: 401 });if (!state.auth.has({ reverification: "strict" })) return reverificationError("strict");Organizations: roles and permissions
Section titled “Organizations: roles and permissions”When the session has an active organization, a signed-in auth carries it from the token’s o claim: orgId,
orgRole (the full key, org:admin), orgSlug (null without one) and orgPermissions (the role’s custom permissions,
org:invoices:read); all are null without an active organization. has({ role }) and has({ permission }) answer from
the token alone, the same way in every Zautha SDK: full keys only, false without an active organization, and always
false for a system permission (org:sys_…, never in the token: check a role or a custom permission instead). Every
condition given must hold, reverification included; has({}) is false.
if (!state.auth.has({ permission: "org:invoices:read" })) return new Response(null, { status: 403 });A token whose o claim is malformed is not accepted, even with a valid signature.
Impersonation
Section titled “Impersonation”A token from an impersonation session carries act (RFC 8693): claims.act is { sub, iss? } and auth.actor is
{ sub, iss } (iss null when absent); both are absent/null for the user’s own session. actorOf(claims) gives the
same from verifyToken’s result. A token whose act is malformed is rejected (token_invalid). Such a session never
passes has({ reverification }): its fva is [-1, -1], and there -1 does not mean “no second factor”.
Start an impersonation from your server with the Backend API client (Pro; zautha is the createZauthaClient of
Manage users):
const token = await zautha.actorTokens.create({ user_id: "user_…", actor: { sub: "<your agent's id>" }, redirect_url: "https://app.example.com/",});// Open token.url in the agent's browser: one use, valid 10 minutes by default (expires_in_seconds, 60–3600); the// session lasts 30 minutes by default (session_max_duration_in_seconds, 60–7200).
// To cancel it while it is unused (afterwards: actor_token_not_pending):await zautha.actorTokens.revoke(token.id);actorTokens.get(id) reads one (status pending, accepted with its session_id, or revoked). Only the
create response carries the url; it is never recorded for idempotency, so create takes no idempotencyKey.
Fastify and plain Node
Section titled “Fastify and plain Node”import { authenticateRequest } from "@zautha/backend";import { toWebRequest, applyHeaders } from "@zautha/backend/node";
const state = await authenticateRequest(toWebRequest(req), options);applyHeaders(res, state.headers);Behind a TLS-terminating proxy pass the public origin: toWebRequest(req, { origin: "https://app.example.com" }).
Manage users (Backend API)
Section titled “Manage users (Backend API)”import { createZauthaClient, isZauthaApiError } from "@zautha/backend";
const zautha = createZauthaClient({ secretKey: process.env.ZAUTHA_SECRET_KEY! });
const { data, total_count } = await zautha.users.list({ query: "ada", limit: 20 });try { const user = await zautha.users.create( { email_address: "ada@example.com", public_metadata: { plan: "pro" } }, { idempotencyKey: "import-ada" }, ); await zautha.users.ban(user.id);} catch (error) { if (isZauthaApiError(error, "form_identifier_exists")) console.log("already there"); else throw error;}users.list/get/create/update/delete/ban/unban, users.removeMfa(userId) (removes a user’s two-step verification —
authenticator app, passkeys and backup codes — when they lost their devices, and signs them out), users.sessions(userId),
sessions.revoke(sessionId), sessions.revokeAll() (ends every live session of the instance; resolves to
{ revoked_count }) and jwks(). Organizations: organizations.list/get/create/update/delete/updateMetadata
(get, update and delete take an id or, while slugs are on, a slug), organizationMemberships.list/create/update/ delete/updateMetadata(organizationId, userId, …) and listForUser(userId), organizationRoles and
organizationPermissions (list/get/create/update/delete; keys never change), and invitations:
organizationInvitations.create/createBulk(organizationId, …) (up to 10, all or none), list(organizationId, { status }),
get and revoke(organizationId, invitationId). Only the create/createBulk response carries the invitation link
(url); with notify: false no email is sent and you deliver the link yourself. Impersonation:
actorTokens.create/get/revoke (see Impersonation). Every create but actorTokens.create takes
{ idempotencyKey }. A user lists the provider accounts they sign in with in external_accounts
(provider, email, email_verified, names, created_at, last_used_at). Errors are ZauthaApiError with status, code, errors[] and traceId. Keep the secret key on
the server.
Verify webhooks
Section titled “Verify webhooks”Each webhook endpoint in the dashboard has a signing secret (whsec_…). verifyWebhook checks the request’s
Standard Webhooks signature and timestamp (at most 5 minutes from your clock) and returns the event; type tells what
data is: a User for user.created/user.updated, { id, object: "user", deleted: true } for user.deleted, a
Session for session.created/session.ended/session.revoked, an Organization for
organization.created/organization.updated, { id, object: "organization", deleted: true } for organization.deleted
an OrganizationMembership for organizationMembership.created/updated/deleted (the deleted one as it was) and an
OrganizationInvitation for organizationInvitation.created/accepted/revoked (url null; accepted_user_id set
once accepted). Verify the body exactly as it arrived: a body parsed
and serialized again no longer matches its signature.
import { verifyWebhook, isZauthaWebhookVerificationError } from "@zautha/backend";
export async function POST(request: Request): Promise<Response> { try { const event = await verifyWebhook(request, { signingSecret: process.env.ZAUTHA_WEBHOOK_SIGNING_SECRET! }); if (event.type === "user.created") console.log(event.data.primary_email_address); return new Response(null, { status: 204 }); } catch (error) { if (isZauthaWebhookVerificationError(error)) return new Response(error.code, { status: 400 }); throw error; }}With Express, read the body raw on that route (express.json() would parse it) and pass it with the headers:
import express from "express";import { verifyWebhook, isZauthaWebhookVerificationError } from "@zautha/backend";
const app = express();
app.post("/webhooks/zautha", express.raw({ type: "application/json" }), async (req, res, next) => { try { const event = await verifyWebhook( { body: req.body, headers: req.headers }, { signingSecret: process.env.ZAUTHA_WEBHOOK_SIGNING_SECRET! }, ); if (event.type === "user.deleted") console.log("delete", event.data.id); res.sendStatus(204); } catch (error) { if (isZauthaWebhookVerificationError(error)) res.status(400).send(error.code); else next(error); }});Errors are ZauthaWebhookVerificationError with code missing_headers, invalid_signature or
timestamp_out_of_tolerance. Answer with a 2xx quickly: anything else, or no answer within 15 seconds, is retried
for up to 5 days. The same message keeps its webhook-id across retries and resends, so use it to skip duplicates.