Skip to content

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

Terminal window
npm i @zautha/backend
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.

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.

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.

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");

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.

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.

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

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.

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.