npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

zitadel-flows

v0.5.0

Published

Headless passwordless auth flow for ZITADEL (email-code login + OIDC finalize) on top of @zitadel/client.

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 ✅
  • Denoimport … from "npm:zitadel-flows"
  • Bundlers / TS loaders that transpile dependencies — Vite, esbuild, tsx, Next.js/Turbopack ✅

[!IMPORTANT] Plain node app.js does NOT work. Node refuses to strip types from files inside node_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 excludes node_modules by default (common with webpack/babel exclude: /node_modules/), allow-list zitadel-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 }) throws ZitadelFlowsError with code CONFIG. Use the interactive flow instead — resendEmailVerification(userId) then confirmEmail({ 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 enabled

6. 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? }

token also 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 a 401.

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