zitadel-flows
v0.5.0
Published
Headless passwordless auth flow for ZITADEL (email-code login + OIDC finalize) on top of @zitadel/client.
Maintainers
Readme
zitadel-flows
Headless passwordless auth for ZITADEL: register a user, send an email code, verify it, log in with an email OTP, and exchange the authenticated session for real ZITADEL OIDC tokens — a single typed API, no hosted UI, no returnCode required in production.
Runtime requirement
This package ships TypeScript source (.ts, no build step). Your runtime/bundler must transpile it. It works under:
- Bun — any recent version ✅
- Deno —
import … from "npm:zitadel-flows"✅ - Bundlers / TS loaders that transpile dependencies — Vite, esbuild,
tsx, Next.js/Turbopack ✅
[!IMPORTANT] Plain
node app.jsdoes NOT work. Node refuses to strip types from files insidenode_modules(ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), even with--experimental-strip-types. Use Bun/Deno, or bundle/transpile with a tool that processes dependencies. If your bundler excludesnode_modulesby default (common with webpack/babelexclude: /node_modules/), allow-listzitadel-flows.
Install
bun add zitadel-flows
# or: npm i zitadel-flows / pnpm add zitadel-flows@zitadel/client and jose come as dependencies — nothing else to install, no .npmrc needed.
Configure
createZitadelAuth needs your instance URL, a service-account key, and the OIDC app credentials.
The service-account user needs the IAM_LOGIN_CLIENT (sessions + OIDC finalize) and
ORG_USER_MANAGER (create/verify users) roles.
import { createZitadelAuth } from "zitadel-flows";
const auth = createZitadelAuth({
url: process.env.ZITADEL_URL!, // e.g. https://auth.example.com (also the OIDC issuer)
// The service-account key: the parsed JSON object OR its JSON string. No base64.
// Easiest from an env var holding the key file's JSON:
serviceAccountKey: process.env.ZITADEL_SERVICE_ACCOUNT_KEY!,
// or load a file: JSON.parse(readFileSync("sa-key.json", "utf8"))
oidc: {
clientId: process.env.ZITADEL_OIDC_CLIENT_ID!,
clientSecret: process.env.ZITADEL_OIDC_CLIENT_SECRET!,
redirectUri: process.env.ZITADEL_OIDC_REDIRECT_URI!, // must match the OIDC app
},
});Examples
1. Register a new user (production — ZITADEL e-mails the code)
ZITADEL sends the verification code to the user; you never fetch it. Works even when the instance disables returning codes via the API. Requires an SMTP provider configured on the instance.
// Step 1 — create the user. ZITADEL e-mails a verification code.
const reg = await auth.startRegistration({ email: "[email protected]" });
// reg.state === "needs_email_verification"; reg.userId is the new user id
// Step 2 — the user reads the code from their inbox and types it; submit what they entered.
// confirmEmail verifies the address AND enables email-OTP → the user is login-ready.
await auth.confirmEmail({ userId: reg.userId, code: codeFromUser });
// (optional) re-send if it expired / never arrived
await auth.resendEmailVerification(reg.userId);2. Log in with an email code
// Step 1 — ZITADEL e-mails a one-time code.
const start = await auth.startLogin("[email protected]");
if (start.state === "needs_email_verification") {
// user exists but hasn't verified their e-mail — run the register step 2 (confirmEmail) first
return;
}
// Step 2 — verify the code the user typed.
const done = await auth.verifyLogin({
sessionId: start.sessionId,
sessionToken: start.sessionToken,
code: codeFromUser,
});
// Step 3 — exchange the authenticated session for real ZITADEL OIDC tokens (JWT access_token).
const tokens = await auth.finalizeOidc({
sessionId: done.sessionId,
sessionToken: done.sessionToken,
});
console.log(tokens.access_token, tokens.id_token);3. Backend HTTP handlers (Hono example)
A typical split: one round-trip to send a code, one to submit it. The client only ever sends the e-mail and the user-typed code.
import { Hono } from "hono";
const app = new Hono();
// — Registration —
app.post("/auth/register", async (c) => {
const { email } = await c.req.json();
const reg = await auth.startRegistration({ email });
return c.json({ userId: reg.userId, state: reg.state }); // "needs_email_verification"
});
app.post("/auth/register/confirm", async (c) => {
const { userId, code } = await c.req.json();
await auth.confirmEmail({ userId, code }); // verifies + enables otp_email
return c.json({ ok: true });
});
// — Login —
app.post("/auth/login/start", async (c) => {
const { email } = await c.req.json();
const start = await auth.startLogin(email);
if (start.state === "needs_email_verification") {
return c.json({ state: start.state, userId: start.userId }, 409);
}
// keep sessionId/sessionToken server-side (e.g. signed cookie / cache); return an opaque ref
return c.json({ state: start.state, sessionId: start.sessionId, sessionToken: start.sessionToken });
});
app.post("/auth/login/verify", async (c) => {
const { sessionId, sessionToken, code } = await c.req.json();
const done = await auth.verifyLogin({ sessionId, sessionToken, code });
const tokens = await auth.finalizeOidc(done);
return c.json(tokens); // { access_token, id_token, refresh_token?, ... }
});4. Backfill existing users (add the email-OTP factor)
For users created outside this library that lack the otp_email factor:
// Already-verified e-mail → just enables otp_email:
await auth.backfillEmailOtp(userId);
// Unverified e-mail you trust (admin-verify server-side) — needs `returnCode` on the instance:
await auth.backfillEmailOtp(userId, { trustEmail: true, email: "[email protected]" });If the instance disables
returnCode,backfillEmailOtp({ trustEmail })throwsZitadelFlowsErrorwith codeCONFIG. Use the interactive flow instead —resendEmailVerification(userId)thenconfirmEmail({ userId, code }).
5. Automation / seeding (where returnCode is enabled)
On dev/test instances that allow returning codes via the API, register() does the whole
create → verify → enable-OTP in one server-side call (no user interaction):
const user = await auth.register({ email: "[email protected]" }); // needs returnCode enabled6. Test JWT for automated tests (no e-mail)
Two helpers give tests a real ZITADEL JWT without any e-mail round-trip. They need the
dedicated test setup (a project + a machine user granted its roles + an OIDC app that issues
JWT access tokens) — see the terraform zitadel-project-test.tf.
Lane A — machine token (no user, no e-mail). Fastest; the token carries the machine user's granted project roles, so it passes API / edge-gateway JWT validation and RBAC-by-claims.
import { machineToken } from "zitadel-flows";
// JWT-profile (a downloaded machine key) — mirrors terraform output `project_test_m2m_key`:
const { access_token } = await machineToken({
url: process.env.ZITADEL_URL!,
projectId: process.env.ZITADEL_TEST_PROJECT_ID!, // terraform: project_test_project_id
serviceAccountKey: process.env.ZITADEL_TEST_M2M_KEY!, // key JSON (object or string)
});
// ...or client-credentials (clientId + clientSecret) instead of a key:
// await machineToken({ url, projectId, clientId, clientSecret });
const res = await fetch(`${API}/api/v1/ping`, {
headers: { Authorization: `Bearer ${access_token}` },
});With projectId the token gets scopes urn:zitadel:iam:org:project:id:<projectId>:aud +
urn:zitadel:iam:org:project:roles, so its audience includes the project and the roles claim
(urn:zitadel:iam:org:project:roles) lists the granted roles.
Lane B — throwaway human console user (returnCode). Exercises the real passwordless login
path end-to-end and returns real OIDC tokens. Needs the login service account
(IAM_LOGIN_CLIENT + ORG_USER_MANAGER) and returnCode allowed on the instance. Roles are
NOT auto-granted — grant them separately if the test asserts on role claims.
import { getTestUserToken } from "zitadel-flows";
const { userId, email, tokens } = await getTestUserToken({
url: process.env.ZITADEL_URL!,
serviceAccountKey: process.env.ZITADEL_TEST_LOGIN_KEY!, // terraform: project_test_login_key
oidc: {
clientId: process.env.ZITADEL_TEST_CLIENT_ID!,
clientSecret: process.env.ZITADEL_TEST_CLIENT_SECRET!,
redirectUri: "http://localhost:5173/auth/callback", // must match the test OIDC app
},
}); // { email: "[email protected]" } to pin the address
// tokens.access_token is a real ZITADEL JWT (JWKS-verifiable, iss/aud set).Env from terraform:
terraform output -raw project_test_project_id,... project_test_oidc_client_id/secret,... project_test_m2m_key,... project_test_login_key. Machine (Lane A) is the default for API/RBAC/gateway tests; use Lane B only when the login flow itself is under test.
Verification e-mail links (urlTemplate)
The code e-mails contain an "Authenticate" button. By default ZITADEL points it at its own
hosted login UI (/ui/v2/login/otp/email), which only works for sessions that UI created — for
headless (Session-API) sessions it fails with "could not get user context". So either tell
users to just type the code, or point the button at your "enter code" page with urlTemplate.
Set it once on the config, or per call (per-call wins). Placeholders: {{.Code}}, {{.UserID}},
{{.OrgID}}, {{.LoginName}}.
const auth = createZitadelAuth({
url,
serviceAccountKey,
oidc,
// default for every sendCode e-mail (login OTP, registration, resend):
urlTemplate: "https://app.example.com/auth/otp?userID={{.UserID}}&code={{.Code}}",
});
// ...or per call (overrides the config default):
await auth.startLogin(email, { urlTemplate: "https://app.example.com/auth/otp?code={{.Code}}" });
await auth.startRegistration({ email }, { urlTemplate: "https://app.example.com/auth/verify?userID={{.UserID}}&code={{.Code}}" });
await auth.resendEmailVerification(userId, { urlTemplate: "https://app.example.com/auth/verify?code={{.Code}}" });Applies only to sendCode flows. It has no effect on returnCode / register() (those send no e-mail).
Login flow states
startLogin() / verifyLogin() results carry a discriminated state:
| state | Meaning | Next step |
|----------------------------|-----------------------------------------------------------------|---------------------------------|
| code_sent | OTP e-mail sent (code in response only if returnCode: true) | verifyLogin() with the code |
| needs_email_verification | User exists but the e-mail isn't verified | run confirmEmail() first |
| authenticated | Session verified; ready for tokens | finalizeOidc() |
Error handling
import { ZitadelFlowsError } from "zitadel-flows";
try {
await auth.confirmEmail({ userId, code });
} catch (e) {
if (e instanceof ZitadelFlowsError) {
console.error(e.code, e.message); // typed code + actionable message
}
}Error codes (ZitadelErrorCode): OTP_NOT_READY · EMAIL_NOT_VERIFIED · CONFIG · TRANSPORT.
startLogin() self-heals ZITADEL's COMMAND-JKLJ3/COMMAND-KLJ2d ("OTP not ready") automatically:
it re-enables the otp_email factor when the e-mail is verified and retries; if it isn't verified
it returns needs_email_verification instead of throwing.
API reference
createZitadelAuth(config): ZitadelAuth
| Config field | Type | Description |
|---------------------|-------------------------------|------------------------------------------------------|
| url | string | Instance base URL (also the OIDC issuer) |
| serviceAccountKey | ServiceAccountKey \| string | Parsed key object, or its JSON string (no base64) |
| oidc.clientId | string | OIDC application client ID |
| oidc.clientSecret | string | OIDC application client secret |
| oidc.redirectUri | string | OIDC redirect URI registered in ZITADEL |
| urlTemplate | string? | Default link template for sendCode e-mails (see "Verification e-mail links"). Placeholders {{.Code}} {{.UserID}} {{.OrgID}} {{.LoginName}} |
ZitadelAuth methods
| Method | Description |
|--------|-------------|
| startRegistration(input, opts?) | Create a user; ZITADEL e-mails the verification code. → { userId, state: "needs_email_verification" }. opts.urlTemplate sets the e-mail link |
| confirmEmail({ userId, code }) | Verify the user-entered code, then enable otp_email (user becomes login-ready) |
| resendEmailVerification(userId, opts?) | Re-send the e-mail verification code (sendCode); opts.urlTemplate sets the e-mail link |
| register(input) | One-shot create + verify + enable OTP server-side — requires returnCode enabled |
| startLogin(login, opts?) | E-mail a login OTP and open a session (opts.returnCode for tests, opts.urlTemplate sets the e-mail link) |
| verifyLogin({ sessionId, sessionToken, code }) | Check the OTP; confirm the session is authenticated |
| finalizeOidc({ sessionId, sessionToken }) | Exchange the session for OIDC tokens (real ZITADEL JWT) |
| backfillEmailOtp(userId, opts?: { trustEmail?, email? }) | Enable otp_email for an existing user; trustEmail admin-verifies the address (needs returnCode) |
Management API
Separate from the passwordless auth flow above, zitadel-flows also ships a thin, battle-tested
client for ZITADEL's raw Management API v1 (+ OIDC introspection): create organizations, import
human users, declare project roles, delegate them cross-org via project grants, create machine
(service-account) clients, rotate their secrets, introspect tokens, and run health probes. This is
the low-level admin wire — no hosted UI, no SDK abstraction, just the v1 endpoints with the
org-context-by-header and userId-vs-clientId fixes baked in.
import {
HttpZitadelManagement, // or the createZitadelManagement(config) factory
managementTokenProvider,
} from 'zitadel-flows';
const mgmt = new HttpZitadelManagement({
url: process.env.ZITADEL_URL!, // issuer / base URL, e.g. https://auth.example.com
managementBaseUrl: `${process.env.ZITADEL_URL!}/management/v1`,
// token is pluggable: a static string OR a provider fn. Prefer the provider — it mints a
// management token from a service-account key (JWT-bearer) and re-mints ~1 min before expiry,
// so it never goes stale (a static admin token does).
token: managementTokenProvider(process.env.ZITADEL_URL!, process.env.ZITADEL_ADMIN_SA_KEY!),
// RFC 7662 introspection client (the `pdp-introspector`), for introspect()/health probes:
introspection: {
clientId: process.env.ZITADEL_INTROSPECT_CLIENT_ID!,
clientSecret: process.env.ZITADEL_INTROSPECT_CLIENT_SECRET!,
},
// project-scoped role/grant endpoints need the owning project + its resource-owner org:
projectId: process.env.ZITADEL_PROJECT_ID!,
projectOrgId: process.env.ZITADEL_PROJECT_ORG_ID!,
});
const { orgId } = await mgmt.createOrg({ name: 'Acme', slug: 'acme' });
const { userId } = await mgmt.addHumanUser(orgId, { email: '[email protected]', displayName: 'Jane Doe' });
await mgmt.ensureProjectRole('tenant-admin');
await mgmt.ensureProjectGrantRole(orgId, 'tenant-admin'); // cross-org delegation (skip for the owner org)
await mgmt.addUserGrant(orgId, userId, 'tenant-admin');
const claims = await mgmt.introspect(accessToken); // { active, roles, tenantId, clientId, username?, sub? }
tokenalso accepts a plain string if you already hold a valid management token. The provider is the recommended form; the transport awaits it on every call (cheap — it caches) and force-refreshes it once on a401.
For tests, use InMemoryZitadelManagement — a deterministic in-memory fake that satisfies the same
ZitadelManagement interface (issues thin tokens, folds granted roles at introspection, mocks health
probes). The free helpers introspect(mgmt, token), provisionIntrospectionClient(mgmt, clientId),
computeHealth(probes) and probeHealth(mgmt) round out the surface. This is the raw Management API
v1 — see the ZITADEL API docs for endpoint semantics.
License
MIT
