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, and 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, and the page is precise about 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 prefix Flow Dispatched before the gate because
/admin/auth/* Passkey ceremonies, recovery sign-in, recovery-code regeneration, first-owner bootstrap These 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 list The 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 POST The 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 path Body Returns
POST /admin/auth/register/begin { email, inviteToken? } The WebAuthn creation options bound to a server-issued challenge
POST /admin/auth/register/finish The 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/finish The assertion { credential } A verified result plus, on success, a Set-Cookie session
POST /admin/auth/logout none Clears 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, and 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, which 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, and 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, and 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.

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, and 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.

POST /admin/auth/recovery-codes/regenerate mints a fresh set for the caller’s own email and invalidates the prior codes. 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.

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/*, and 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 (handleOidc, engine/src/admin/router-idp-web.ts).

Method and path Purpose Returns
GET /admin/oidc/providers The pre-auth display list for the sign-in buttons A safe display object with no internal configuration
GET /admin/oidc/start/<connId> Begin a login for one configured connection A 302 to the provider, plus the transaction cookie
GET /admin/oidc/callback/<connId> The provider’s redirect back A 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, and 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, and 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 in this build: 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 path Purpose Returns
GET /admin/saml/metadata/<connId> The service-provider descriptor the operator uploads to their provider The metadata XML
GET /admin/saml/start/<connId> SP-initiated login for one connection A 302 to the provider’s sign-on URL, plus the browser-binding cookie
POST /admin/saml/acs/<connId> The provider’s assertion consumer POST A 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 assertion with no InResponseTo, the shape an unsolicited provider-initiated login takes, is rejected 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, which a captured assertion replayed in another browser cannot satisfy. 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 attribute Value Why
Name __Host-downpipes_session The __Host- prefix pins the cookie to this exact host over HTTPS and forbids a sibling or parent domain from setting or overriding it
HttpOnly set No script access, so a script injection cannot read the session
Secure set Sent only over HTTPS
SameSite Strict Never 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
Lifetime 12 hours absolute, with a 2-hour idle bound The 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. See session management for the model

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, and 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, keyed on the edge-supplied connecting-IP header in its own namespace, sits after the 404 and before the body read on the /admin/auth/* routes (authRateLimited, engine/src/admin/router-core.ts). 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.

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.

Status Where When What it means
401 /admin/auth/recovery A bad code, an unknown email, or an exhausted set A generic sign-in failure with no detail
200 with { ok: false, reason, errorId } /admin/auth/login/finish, /admin/auth/register/finish A failed assertion or an unauthorised registration A coarse ceremony failure; the caller must read the body, not the status
403 /admin/auth/logout, recovery-codes/regenerate A cross-site mutating request The strict-Origin CSRF check failed
429 /admin/auth/* Too many sign-in attempts from one IP Rate limited; retry after the header value
501 /admin/auth/* ceremonies CONSOLE_ORIGIN is unset The 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 flow A 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 .