Skip to content
downpipes docs

Sign-in flows: passkey, OIDC and SAML, plus bootstrap and recovery

This page documents the engine endpoints that let a person obtain a session. It is written for a developer who is integrating with, proxying, or auditing the sign-in surface. Obtaining a session cannot itself require a session, so every endpoint here runs before the authorisation gate that protects the rest of the admin API. They are unauthenticated by necessity. The sections below set out what protects each one in the absence of a prior credential.

There are three pre-auth sign-in flows: the engine’s own passkey front door, native OIDC, and native SAML. Each lives under /admin/*, so the in-account console proxies it verbatim and a sign-in path never falls through to the single-page app where it could leak a code into client script. Alongside the sign-in flows sit two account-bootstrap and break-glass routes, the first-owner set-up link and recovery-code sign-in, which share the same pre-gate placement. Everything authenticated, including the role gate and the session-context surface, is covered separately in authentication and authorisation.

Keep two break-glass concepts distinct. The recovery codes and the admin-token credential on this page are about getting back into the console. They are not the offline break-glass key that recovers your encrypted data without the engine. That key, and the recovery kit it lives in, are covered in break-glass offline recovery.

Where the pre-auth dispatch sits

The admin handler checks three path prefixes before it calls the authorisation gate, because each is part of obtaining a credential rather than presenting one (handleAdmin, engine/src/admin/router.ts). The order is deliberate: a probe at an unrelated sub-path is a plain 404 before any body is read or any Durable Object is touched.

Path prefixFlowDispatched before the gate because
/admin/auth/*Passkey ceremonies, recovery sign-in, recovery-code regeneration, first-owner bootstrapThese are the WebAuthn sign-in flow itself; requiring a prior credential would be circular
/admin/oidc/*Native OIDC start and callback, plus the provider display listThe callback carries the authorisation code and must not reach the single-page app
/admin/saml/*Native SAML metadata, SP-initiated start, and the assertion consumer POSTThe assertion consumer is an identity-provider-driven cross-site POST that arrives with no session

After those three prefixes, authorise() runs and the rest of the admin API is gated. The scheduler Durable Object is the verification authority for all of it: it holds the WebAuthn challenge records, the session signing key, the OIDC and SAML transaction records, and the recovery-code hashes, and the router is a thin forwarder that supplies only server-controlled values. The router never trusts a client-supplied origin, relying-party id, or authentication method (handlePasskey, engine/src/admin/router-auth-flow.ts; handleOidc, handleSaml, engine/src/admin/router-idp-web.ts).

The passkey front door (WebAuthn)

The engine ships its own WebAuthn sign-in so an account can authenticate without Cloudflare Access, which is not free past fifty users. The ceremonies are a thin forwarder onto the Durable Object, which is the storage and verification authority. The router resolves the relying-party id and the origin from CONSOLE_ORIGIN and forwards them with the client body, so a client cannot assert its own origin (passkeyOriginAndRpId, engine/src/admin/router-session.ts; handlePasskey, engine/src/admin/router-auth-flow.ts).

Method and pathBodyReturns
POST /admin/auth/register/begin{ email, inviteToken? }The WebAuthn creation options bound to a server-issued challenge
POST /admin/auth/register/finishThe attestation { credential }A verified result plus, on success, a Set-Cookie session
POST /admin/auth/login/begin{ email }The WebAuthn request options bound to a fresh challenge
POST /admin/auth/login/finishThe assertion { credential }A verified result plus, on success, a Set-Cookie session
POST /admin/auth/logoutnoneClears the cookie and terminates the session server-side

Both finish ceremonies mint a session only on a verified success. The router reads the Durable Object’s finish result, and only an ok: true body carrying the proven email triggers a session mint and the hardened Set-Cookie (sessionCookieForFinish, engine/src/admin/router-auth-flow.ts). A begin route, or a finish that did not verify, sets no cookie, so only a completed WebAuthn ceremony issues a session.

Registration is authorised by a proven path, never by the client-supplied email alone, because WebAuthn proves possession of a key rather than ownership of an address. The router runs the same authorise() the main gate uses and forwards only the proven method and verified email. The Durable Object derives the bound email from the proof. There are three accepted registration paths.

  1. Bootstrap: a valid admin-token bearer claims the first Owner, and only when the role table is empty, which the Durable Object checks atomically.
  2. Invite: a single-use, email-bound invite token that an Owner minted for a not-yet-enrolled address, where the bound email comes from the invite.
  3. Self-add: an already-authenticated caller adds a credential to their own email, and the Durable Object refuses unless the proven email equals the registration email.

Logout terminates the session on the server rather than only clearing the browser cookie. A clear-only logout would leave a captured copy of the bearer valid for the rest of its lifetime, so the engine bumps this identity’s session epoch and not-before instant in the Durable Object, which fails the just-logged-out token and any copy closed on the next request (handlePasskey, the logout branch, engine/src/admin/router-auth-flow.ts). Because logout is a state-changing request on the ambient cookie, it carries the same strict-Origin check as every other mutating cookie-borne route. The kill is best-effort: a missing token or a momentary Durable Object fault never blocks the logout, and the cookie is always cleared.

First-owner bootstrap

A fresh engine has no Owner, and the console needs a way to seed the first one without a terminal. POST /admin/auth/bootstrap/send asks the engine to email the first-owner set-up link to the deploy-time owner address. That address is read from the environment and never from the client body (sendBootstrapLink, engine/src/admin/router-auth-flow.ts).

The route is built to be no oracle for the engine’s bootstrap state. It returns the same generic 200 for every outcome, whether the link was sent, the address was unconfigured, or an Owner already exists. It logs only a coarse operator-facing reason that never carries the token, the link, or the address. It keeps the shared per-IP rate limit because it can trigger an outbound email, the costliest action on this surface.

The minted token rides in the URL fragment of the link so it stays out of server access logs. The link works once, only for the pinned address, and only while the engine still has no Owner, and it expires in twenty-four hours. Requesting a new link replaces the previous one.

The link is only ever sent to the owner address pinned at deploy time, so pressing the button that sends it grants nothing to the presser. The Durable Object enforces the empty-table and unconsumed-latch preconditions atomically, so a null mint result means the path is already closed and the route still answers a generic 200.

Creating a second Owner from the admin token

Once a first Owner exists, the admin token can never mint another one; bootstrapConsumed closes that path for good, even if the role table is later emptied. Retiring the token needs a way back in first, an Owner with working recovery codes or a second Owner, so if the original Owner’s passkey and recovery codes are both gone, the token is still live and this is the only reachable way to stand up that second Owner with it.

The admin-token sign-in itself does not offer a role grant. It connects the client and lands on /register without ever calling whoami, so no identity is resolved there and that screen carries no role-grant control of its own. The route that works is setup’s optional “Invite your team” step, at /setup/team, reached from /register.

  1. Sign in with the admin token

    At /passkey, open “First time here, or need another way in?” and use “Sign in with the admin token”. A verified token connects the client and lands on /register.

  2. Follow Invite your team

    On /register, follow “Invite your team”. It resolves the token’s identity (so it reads as the Owner it always was) and opens setup’s team step at /setup/team.

  3. Grant the Owner role to a NEW email

    Enter an email the engine has never enrolled and choose the Owner role, then Grant role. The address must have no existing passkey: the engine’s registration gate refuses to bind a credential onto an email that already has one, so this can stand up a fresh Owner but can never rebind your original, lost account (resolveRegistrationAuthorisation, engine/src/sched/scheduler-do-passkey.ts; resolveRegisterAuth, engine/src/admin/router-auth-flow.ts).

  4. The grant mints a single-use enrolment link (${CONSOLE_ORIGIN}/#/register?invite=<token>). Copy it and send it to the new Owner out of band, or let the engine email it if invite email is configured on this deployment.

  5. The new Owner enrols a passkey

    Opening the link lands the new Owner on /register in set-up mode, bound to the email the invite was minted for, never a value the browser supplies. Registering a passkey there completes the grant and shows their one-time recovery codes; save them immediately, the same as any first enrolment.

  6. Sign in as the new Owner and retire the token

    Sign in with the new Owner’s passkey, open Security Centre, and retire the break-glass token. A second Owner now exists, so the retire is no longer refused for want of a way back in; see retire the token once a way back in exists.

The token cannot rebind an email that already holds a passkey

Re-inviting the original Owner’s own address does not work. Registering a NEW passkey against an email that already carries one is refused, whether the invite goes through this route or the admin token tries to self-add the credential directly: the Durable Object binds a passkey only to a proven identity or to an email with no credential yet, and an already-enrolled address is neither. A lost passkey is recovered with a saved recovery code, never by re-enrolling the same address through the token.

Recovery-code sign-in and regeneration

Recovery codes are the ongoing console break-glass for a user who lost their passkey. They are not the offline data-recovery key. Two routes manage them, and they sit under /admin/auth/* for the same pre-gate reason as the ceremonies.

POST /admin/auth/recovery takes { email, code } and, on a match, issues a normal signed session for that email’s role, exactly as a passkey login would. It is unauthenticated by necessity, so it is hard rate-limited in the Durable Object per IP and per email and fails closed, and every failure is a single generic 401 with no detail, so it is no oracle for which codes or accounts exist (handleRecovery, engine/src/admin/router-auth-flow.ts). On success the token is set in the hardened cookie, never returned in the body. The response asks the console to prompt a fresh passkey enrolment and reports the remaining code count. A successful use and a detected abuse pattern both route an alert through the notification path, so a break-glass sign-in is loud.

Completing that prompted enrolment does not throw away the codes you still have. If the account already holds a working set, register/finish mints the fresh replacement to a STAGED key rather than overwriting the live one (recoveryCodesPending: true in its response, generateRecoveryStaged, engine/src/sched/scheduler-do-recovery.ts): every code in the existing set keeps verifying. The console shows the new set once, and only once you confirm you have saved it does it call POST /admin/auth/recovery-codes/confirm. That call promotes the staged set to live and is the moment the old codes actually stop working (confirmRecoveryStaged). Close the tab, lose the connection, or dismiss the panel before confirming, and nothing is lost: the set you already had is still the one that works. A first-ever enrolment (bootstrap or an invite) has no prior set to protect, so it mints straight to live and reports no pending state.

POST /admin/auth/recovery-codes/regenerate mints a fresh set for the caller’s own email and invalidates the prior codes immediately, with no staging step: this is a deliberate, self-service action the console warns about before it runs. Unlike the other /admin/auth/* routes it is authenticated inside: it runs the same authorise() as the main gate to identify the caller, then scopes the regeneration to the caller’s verified email, so a user can only ever regenerate their own codes (handleRegenerate, engine/src/admin/router-auth-flow.ts). The bare admin-token break-glass has no email, so it cannot hold or regenerate a per-user set and is refused with a 403. The fresh plaintext set is returned exactly once for display and is never stored as plaintext or returned again; a later read sees only the count.

POST /admin/auth/recovery-codes/confirm promotes a staged set (minted by the forced re-enrolment above) to live, scoped the same authenticated, own-email-only way regenerate is (handleConfirmStaged, engine/src/admin/router-auth-flow.ts). It is a harmless no-op, { ok: true, promoted: false }, when nothing is staged for the caller: already confirmed, never staged, or superseded by a later enrolment.

Native OIDC

The native OIDC flow is the analogue of the passkey front door for an external identity provider. It has three routes under /admin/oidc/* (handleOidc, engine/src/admin/router-idp-web.ts). Every failure is a single generic redirect to the console sign-in page with a coarse ?oidc=failed flag, never an oracle for which check failed.

Method and pathPurposeReturns
GET /admin/oidc/providersThe pre-auth display list for the sign-in buttonsA safe display object with no internal configuration
GET /admin/oidc/start/<connId>Begin a login for one configured connectionA 302 to the provider, plus the transaction cookie
GET /admin/oidc/callback/<connId>The provider’s redirect backA 302 to the relative landing, plus the session cookie

Start mints the state, nonce, and PKCE challenge in the Durable Object, sets a short transaction cookie, and redirects to the provider’s authorisation endpoint with Referrer-Policy: no-referrer. The redirect_uri is built from CONSOLE_ORIGIN, the canonical public host the provider has pre-registered, never from the request host. Callback cross-checks the transaction cookie against the single-use state record, runs the login-CSRF and issuer binds, performs the guarded token exchange and id-token verification in the Durable Object, mints the session, sets the session cookie, clears the transaction cookie, and redirects to the relative-only landing path. A provider-returned error, or a missing code, state, or transaction cookie, is refused as a generic failure.

The transaction cookie is __Host--prefixed and SameSite=Lax, not Strict, because the callback is a top-level navigation the provider triggers from its own origin, on which a Strict cookie would not be sent and the binding would be lost. Lax is sent on exactly that top-level cross-site GET. It is no weaker here because the value is single-use and cross-checked in the Durable Object (OIDC_TXN_COOKIE, engine/src/admin/router-idp-web.ts).

Across the catalogue there are 8 named providers plus generic OIDC and OAuth2. The named templates pre-fill the endpoints and claim names so an operator types only a handful of provider-specific values plus the client id and secret. The two generic connectors absorb any compliant provider and the no-id-token case respectively. The catalogue and the connection setup are covered in supported providers and connect OIDC and OAuth2.

Only two client-credential modes resolve at the token exchange: a public client that holds nothing and uses PKCE only, and the do-plaintext floor that keeps the secret in a write-only Durable Object key. The secrets-store and private-key-jwt modes validate and save, but the token-exchange resolver refuses them, so a connection configured for either saves successfully and then fails at first login (handleOidcCallback, engine/src/admin/oidc-store.ts). Choose a working mode for any connection you intend to sign in with.

Native SAML

The native SAML flow is the SAML 2.0 service-provider analogue of the OIDC flow, with three routes under /admin/saml/* (handleSaml, engine/src/admin/router-idp-web.ts).

Method and pathPurposeReturns
GET /admin/saml/metadata/<connId>The service-provider descriptor the operator uploads to their providerThe metadata XML
GET /admin/saml/start/<connId>SP-initiated login for one connectionA 302 to the provider’s sign-on URL, plus the browser-binding cookie
POST /admin/saml/acs/<connId>The provider’s assertion consumer POSTA 302 to the relative landing, plus the session cookie

This service provider is sign-only. It does not sign authentication requests and holds no decryption key, so its descriptor advertises AuthnRequestsSigned="false" and carries no key descriptor, while WantAssertionsSigned="true" tells the provider the engine refuses an unsigned assertion (buildSpMetadata, engine/src/admin/saml/metadata.ts). Encrypted assertions are out of scope, and an encrypted assertion is rejected with a clear reason (verifySamlResponse, engine/src/admin/saml/response.ts). The engine pins the provider’s signing certificate out of band and ignores the assertion’s own embedded key, which is the property that makes a forged assertion fail.

The flow is SP-initiated. Start mints the authentication request and a single-use relay-state record in the Durable Object and redirects to the provider; the assertion consumer consumes that single-use relay state, requires a matching browser-binding cookie, verifies the signed assertion against the pinned certificate, and mints the session. An unsolicited provider-initiated login takes the shape of an assertion with no InResponseTo. The engine rejects it unless an Owner has explicitly opted that connection into accepting one (verifySamlResponse, the allowIdpInitiated branch, engine/src/admin/saml/response.ts).

The assertion consumer is a cross-site form POST that carries no SameSite cookie, so it is not subject to the cookie-CSRF Origin guard. Its anti-replay defence is the single-use relay state bound to the InResponseTo round-trip and the browser-binding cookie. A captured assertion replayed in another browser satisfies neither. The descriptor’s assertion-consumer URL is built from CONSOLE_ORIGIN, the canonical host the request declares and the assertion’s recipient is checked against, never the request host. SAML setup is covered in connect SAML.

Every successful sign-in, whatever the method, results in the same signed session cookie. The console presents that cookie on each later /admin call, and authorise() reads it and treats it as the cookie-borne method the token itself records.

The session token is a server-signed message authentication code, not a bearer secret a client could forge. It is the base64url body and a dot and the base64url HMAC-SHA-256 of that body, where the signing key is generated once and persisted only in the scheduler Durable Object, so the constant-time verification and the expiry check happen inside the Durable Object and the key never leaves it (engine/src/admin/session.ts). The body carries the method, the verified email, the stable subject, an optional connection id for an OIDC or SAML session, the session epoch, the issued-at (iat), the last-activity instant (lastSeen, the reference that drives the 2-hour idle timeout and is refreshed by the slide), and an absolute expiry. The role is never carried in the token; it is re-resolved from the subject-keyed role table on every request, so a session can never pin an elevated role.

Cookie attributeValueWhy
Name__Host-downpipes_sessionThe __Host- prefix pins the cookie to this exact host over HTTPS and forbids a sibling or parent domain from setting or overriding it
HttpOnlysetNo script access, so a script injection cannot read the session
SecuresetSent only over HTTPS
SameSiteStrictNever sent on a cross-site request, the first line of CSRF defence
Path/Required by the __Host- prefix; the engine still reads it only under /admin
Lifetime12 hours absolute, with a 2-hour idle boundThe 12 hours is the absolute cap, set once at mint and never extended past it. An active session is slid, its lastSeen re-minted roughly every 15 minutes (SESSION_SLIDE_MS) while preserving the original mint and expiry, and a session idle for 2 hours is rejected at verify before the absolute cap is reached. So a leaked cookie dies at the 12-hour deterministic time at the latest, and sooner if it sits unused. A SAML assertion’s SessionNotOnOrAfter shortens the cap to the provider’s session end; an OIDC or OAuth2 session carries no provider bound. See session management for the model
Concurrent sessionsUnlimited, with no maximum-reached behaviourA sign-in from a browser with no live session cookie is always admitted and evicts nothing. A sign-in from a browser that still presents a live session cookie ends every session of that account, because the engine bumps the account’s email and subject epochs before it mints the new session. Otherwise a session ends when it expires or when someone ends it. Sign out one session revokes that one session by its id. Sign out my other sessions, sign out a member’s sessions, a logout and an offboarding bump the per-email epoch; sign out my other sessions then re-issues the session it runs from. Sign out everyone deletes the session signing key, so every session fails its next verification. See session management

CSRF defence is SameSite=Strict on the cookie plus a strict-Origin check on every mutating cookie-borne request. originAllowed admits a state-changing request only when its Origin header exactly equals CONSOLE_ORIGIN. It fails closed on a missing header or an unset origin (originAllowed, engine/src/admin/session.ts). The console always sends Origin on a fetch, so a legitimate write always carries it, while a cross-site forgery either omits it or carries a foreign origin and is refused. An Access JWT or the bare admin-token is presented in an explicit header a foreign page cannot set on a credentialed cross-site request, so those methods are not ambient-credential CSRF vectors and the Origin check does not apply to them.

Rate limiting and generic failures

The ceremony routes are unauthenticated and each finish runs heavy cryptography, so an unthrottled flood is both a denial-of-service vector and an account-enumeration vector. A single per-IP limiter is keyed on the edge-supplied connecting-IP header in its own namespace. It sits after the 404 and before the body read on the /admin/auth/*, /admin/oidc/* and /admin/saml/* routes (authRateLimited, engine/src/admin/router-core.ts). It admits at most thirty attempts a minute from one address. It admits a request only on an explicit allow verdict and otherwise returns a 429 with a Retry-After, so a refusal, a garbled verdict, or an unavailable backing store all deny rather than open the gate. The one deliberate admit is a request that arrives with no connecting-IP header at all, which at the custom-domain edge cannot happen, so the accept covers only a non-edge local caller rather than an attacker who stripped the header.

Recovery-code sign-in sits behind that same thirty-a-minute ceremony cap and, on top of it, its own pair of hard limits inside the scheduler Durable Object: at most five attempts a minute against one email, and, separately, at most five attempts a minute from one address (recoveryRateAllow, engine/src/sched/scheduler-do-recovery.ts). Either bucket refusing, or the limiter’s own store failing, denies the attempt.

The bare ADMIN_TOKEN bearer, presented earlier on this page to create a second Owner, is not a ceremony route and carries no cookie or challenge, so it is throttled where it is checked: inside the authorisation gate itself, at ten attempts a minute from one address, before the token is ever compared. See rate limiting and the 429 for that limiter’s exact response and its fail-closed posture.

Every sign-in failure on every flow collapses to one coarse response so none of them is an oracle. The recovery path returns a plain 401; the passkey finish ceremonies return HTTP 200 with a coarse { ok: false, reason, errorId } body, so a caller must check the body rather than the status; the OIDC and SAML paths return a generic failure redirect; and the bootstrap route returns a generic 200. Authentication failures are logged at the boundary with the attempted method and a short reason only, never a token, an assertion, or an email.

StatusWhereWhenWhat it means
401/admin/auth/recoveryA bad code, an unknown email, or an exhausted setA generic sign-in failure with no detail
200 with { ok: false, reason, errorId }/admin/auth/login/finish, /admin/auth/register/finishA failed assertion or an unauthorised registrationA coarse ceremony failure; the caller must read the body, not the status
403/admin/auth/logout, recovery-codes/regenerate, recovery-codes/confirmA cross-site mutating requestThe strict-Origin CSRF check failed
429/admin/auth/*Too many sign-in attempts from one IPRate limited; retry after the header value
501/admin/auth/* ceremoniesCONSOLE_ORIGIN is unsetThe engine refuses to run a ceremony with no origin to bind against
302 to ?oidc=failed/admin/oidc/*, /admin/saml/*Any failed check in the flowA generic failure landing, no detail of which check failed

Where this fits

These endpoints obtain a session. For the gate that runs after them, the role model, and the session-context surface, see authentication and authorisation. For the broader single sign-on picture and the provider catalogue, see the SSO overview and supported providers. For how a lost passkey is recovered and how sessions are revoked across the account, see session management. For the offline key that recovers your data without the engine, a different break-glass entirely, see break-glass offline recovery.

Last updated .