← Documentation

Magic Link Authentication

The Admin Portal's own token flow: how a link becomes a session, and where it differs from the vendor dashboard's sign-in.

Issuing the link

A vendor operator generates an admin-portal link from the dashboard (or by calling POST /api/v1/admin-portal/generate-link directly, under vendor JWT auth). The server:

  1. Generates 48 random bytes and hex-encodes them into a raw token.
  2. SHA-256 hashes the raw token and stores only the hash on a new AdminPortalSession row, alongside the account ID, the admin's email, an optional display name, and an expiresAt set from ADMIN_PORTAL_TOKEN_EXPIRY_MINUTES (default 30 minutes).
  3. Builds a portal URL from the raw token and emails it: {ADMIN_PORTAL_BASE_URL}/portal?token={raw token}&org={account slug}.

The raw token is never persisted — only its hash is. If the invite email fails to send, the session row still exists and the endpoint reports emailSent: false in its audit record rather than failing the request outright.

Verifying the link

The portal's entry page (/portal) reads ?token= off the URL on mount and immediately calls:

GET/api/v1/admin-portal/session

The token travels in an X-Admin-Token header. The authenticateAdminPortal middleware reads that header only — it does not accept the token as a query parameter, so the ?token= in the emailed link is consumed by the browser and never replayed to the API.

Authentication runs in two phases:

  1. Bootstrap. The middleware hashes the presented token and consumes it atomically: a single conditional write sets usedAt where the hash matches, usedAt is still null, and expiresAt is in the future. Exactly one request can win that write; every later presentation of the same token matches zero rows.
  2. Continuation. Because the bootstrap token is now spent, the same response hands back a short-lived JWT (kind: "admin-portal-session", expiring after ADMIN_PORTAL_TOKEN_EXPIRY_MINUTES) three ways at once: an httpOnly realmsso_admin_session cookie, an x-admin-session response header, and — on GET /session — a field in the body. Later requests present it back as the X-Admin-Session header or that cookie.

A successful GET /session returns the account's name, slug, logo, and brand color plus the admin's email, display name, and the session's expiry. A spent bootstrap token does not 401 on its own — the middleware falls through to the continuation check, because a normal portal client re-sends the bootstrap token from sessionStorage on every call and would otherwise be logged out on its second request. A spent token with no valid continuation is the real failure: it 401s, and it records a login.failure observability event carrying reason: "already_used" or "expired" (see Monitoring & Observability).

On the client, the raw token is written to sessionStorage (not localStorage) under realmsso_admin_token, and the URL is immediately rewritten with history.replaceState so the token doesn't linger in browser history or get shared via a copied link.

X-Admin-Session is not in the API's CORS allow-list

The server allows exactly Content-Type, Authorization, X-API-Key, and X-Admin-Token as cross-origin request headers. The X-Admin-Session header is not among them, so a browser on a different origin than the API cannot send the continuation that way — it has to rely on the realmsso_admin_session cookie, which needs the portal and the API to be same-site. Plan the portal and API hostnames accordingly, or add the header to CORS_ORIGINS' companion allow-list on the server.

Session lifetime, end to end

Config keyDefaultGoverns
ADMIN_PORTAL_TOKEN_EXPIRY_MINUTES30How long an AdminPortalSession is valid after generate-link
ADMIN_PORTAL_BASE_URLhttp://localhost:4000/adminBase URL used to build the emailed portal link

Single-use is enforced by the server, not by the client

The link is genuinely burned on first use. Nothing the portal UI does — or forgets to do — affects that, because the middleware performs the consume as part of authenticating the very first request, whatever route that request happens to hit.

POST /api/v1/admin-portal/session/use still exists, but it is now a backward-compatible no-op: it authenticates, records an admin_portal.session_consumed audit entry, and returns. An older portal build that posts there keeps working; a portal that never calls it is not less secure.

Not the vendor magic link

The vendor dashboard has its own, separate magic-link sign-in for VendorUser accounts (POST /api/v1/auth/magic-link, GET /api/v1/auth/verify). It uses the same random-token-plus-SHA-256-hash pattern, but stores the hash on VendorUser.magicLinkToken instead of a dedicated session table, and is governed by MAGIC_LINK_EXPIRY_MINUTES instead of ADMIN_PORTAL_TOKEN_EXPIRY_MINUTES above. Critically, its email link points at {DASHBOARD_BASE_URL}/auth/callback, a browser page that exchanges the token for a real signed JWT before the vendor ever reaches /dashboard. The Admin Portal reaches the same shape by a different route: there is no dedicated callback page, and the exchange happens inside the middleware on the first authenticated request, which returns the continuation JWT described above.

MAGIC_LINK_SECRET is required but unused

env.ts requires MAGIC_LINK_SECRET (minimum 32 characters) at startup, which reads like it should be mixed into the vendor magic-link hash. It isn't: modules/auth/routes.ts hashes the raw token with plain SHA-256, exactly like the admin-portal flow above — no secret involved. Today MAGIC_LINK_SECRET only fails the server's boot-time environment validation if it's missing or too short; it plays no role in issuing or verifying a token.

These two flows share code shape but no data: a vendor magic-link token is never accepted by /api/v1/admin-portal/* routes, and an admin-portal token is never accepted by /api/v1/auth/verify. For what a customer admin can do once inside, see Portal Overview.

# Shape of the emailed link
{ADMIN_PORTAL_BASE_URL}/portal?token=<96-hex-char raw token>&org=<account slug>

# First authenticated request — bootstrap. Consumes the token, returns the
# continuation as an x-admin-session response header + realmsso_admin_session cookie.
GET  /api/v1/admin-portal/session               X-Admin-Token: <raw token>

# Every later request — either header works
GET  /api/v1/admin-portal/connections           X-Admin-Session: <continuation JWT>
POST /api/v1/admin-portal/connections/saml      X-Admin-Session: <continuation JWT>
POST /api/v1/admin-portal/connections/oidc      X-Admin-Session: <continuation JWT>
PUT  /api/v1/admin-portal/connections/:id/saml  X-Admin-Session: <continuation JWT>
PUT  /api/v1/admin-portal/connections/:id/oidc  X-Admin-Session: <continuation JWT>
PUT  /api/v1/admin-portal/connections/:id/scim  X-Admin-Session: <continuation JWT>

Every route in this module accepts the portal token. The portal UI uses the token-authenticated SAML and SCIM routes above, but its OIDC tab still posts to the vendor-authenticated POST /api/v1/connections/oidc instead — see the callout on Portal Overview for why that one currently 401s.