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:
- Generates 48 random bytes and hex-encodes them into a raw token.
- SHA-256 hashes the raw token and stores only the hash on a new
AdminPortalSessionrow, alongside the account ID, the admin's email, an optional display name, and anexpiresAtset fromADMIN_PORTAL_TOKEN_EXPIRY_MINUTES(default 30 minutes). - 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:
/api/v1/admin-portal/sessionThe 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:
- Bootstrap. The middleware hashes the presented token and consumes it atomically: a single conditional write sets
usedAtwhere the hash matches,usedAtis stillnull, andexpiresAtis in the future. Exactly one request can win that write; every later presentation of the same token matches zero rows. - Continuation. Because the bootstrap token is now spent, the same response hands back a short-lived JWT (
kind: "admin-portal-session", expiring afterADMIN_PORTAL_TOKEN_EXPIRY_MINUTES) three ways at once: anhttpOnlyrealmsso_admin_sessioncookie, anx-admin-sessionresponse header, and — onGET /session— a field in the body. Later requests present it back as theX-Admin-Sessionheader 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 key | Default | Governs |
|---|---|---|
| ADMIN_PORTAL_TOKEN_EXPIRY_MINUTES | 30 | How long an AdminPortalSession is valid after generate-link |
| ADMIN_PORTAL_BASE_URL | http://localhost:4000/admin | Base 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.