Authentication
How you get into the Customertimes platform: WebAuthn passkeys wrapped in a local OAuth 2.0 + PKCE flow, self-registration, magic-link recovery, HttpOnly session cookies, the desktop bridge device link, and route protection.
On this page
TL;DR: The platform stores no passwords. Everyone signs in with a
passkey (WebAuthn) on /admin/login. The passkey challenge is wrapped in a
local OAuth 2.0 Authorization Code flow with PKCE, and a successful sign-in ends
with a JWT access token in an HttpOnly session cookie. Registration at
/admin/register is open on this evaluation instance and the registrant
chooses admin or user; an admin can also create users, and the person then
registers a passkey against the same email. Roles and the user's project
(Commerce or Core) are edited by an admin in the console, and they decide what
a signed-in user can reach.
The desktop bridge is not a second login: linking a device sends you through the same browser sign-in and mints a device credential from the session that sign-in created.
Signing in with a passkey
When you open /admin/login there is no username or password form. The page
starts the OAuth flow immediately, the browser prompts for your passkey
(Touch ID, Windows Hello, a security key, or a platform authenticator), and on
success you land on the dashboard.
| State | What you see |
|---|---|
| Loading | A spinner with "Processing..." while the flow runs |
| Error | A red message describing what went wrong |
| Retry | A "Try again" link that restarts the flow |
How the flow works
- Start — The login page generates a random PKCE code verifier and a
SHA-256 code challenge, both held in
sessionStorage. - Authorise — The browser is sent to the platform's own OAuth
authorisation endpoint with the challenge, a CSRF
statevalue, themarketplace-adminclient id and theuserscope. - Passkey challenge — The authorisation endpoint issues a WebAuthn challenge; the browser signs it with the passkey enrolled for your email.
- Callback — On success the browser returns to
/admin/login?code=.... - Token exchange — The page swaps the authorisation code and the original code verifier for tokens.
- Session — Core sets
access_tokenandrefresh_tokenas HttpOnly cookies and records a server-side session. - Redirect — You go to the dashboard, or to the
?redirect=target you arrived with.
If you visit /admin/login while already holding a valid access token, the
page skips the flow and redirects you straight to the dashboard.
Registration
Open registration
/admin/register asks for a name, an email and the role to explore:
user or admin. This evaluation instance keeps registration open, exactly
as the systemprompt.io template does, so anyone can create an account and see
the platform from either side. Registration issues a one-shot setup token and
sends you to /admin/add-passkey to enrol your first passkey. Until a passkey
is enrolled the account cannot sign in.
The admin user the deploy bootstraps (ed@systemprompt.io) is claimed the
same way: register with that email and the passkey attaches to it.
Admin-created accounts
An admin can also create a user at /admin/access/users with their name and
email, and set the role and project on the detail page. The person then opens
/admin/register and registers with that same email, which attaches the
passkey to the existing account and keeps what the admin set.
Closing registration
Set security.allow_registration: false in the profile when the evaluation
is over. /admin/register still renders, but the register call is refused for
any email that is not already an admin-created account; see
User & Access Management.
Roles and project
Roles are not carried in the session token. They are read from the user record on every request, so promoting or demoting someone takes effect on their next request with no sign-out. The OAuth scope minted into the JWT is fixed at issue time, so a change that widens the scope itself still needs a fresh sign-in.
Every non-admin belongs to exactly one project, Commerce or Core, set by an
admin on the user's detail page. The project is what dashboards, listings and
reports are split by. Entitlement to MCP servers, plugins and gateway routes is
role-based, from services/access-control/roles.yaml.
Magic-link recovery
If you lose the device holding your passkey, a magic link gets you back in so you can enrol a new one.
- Request —
POST /admin/api/magic-link/requestwith your email. - Token — If the email belongs to an existing user, a random 32-byte token is generated and its SHA-256 hash stored with a 15-minute expiry.
- Delivery — No SMTP is wired by default. The token is written to the
server log with the requesting email, so an operator reads it from
systemprompt infra logs viewand passes it to you out of band. - Validate —
POST /admin/api/magic-link/validatewith the token consumes it (single use) and establishes a session, from which you can add a passkey.
| Property | Detail |
|---|---|
| Token length | 32 random bytes (64 hex characters) |
| Storage | SHA-256 hash only |
| Expiry | 15 minutes |
| Usage | Single use |
| Rate limit | 3 tokens per email, and a per-IP limit, in a 15-minute window |
| Enumeration protection | The same response whether or not the email exists |
Operator recovery from the CLI
Recovery does not depend on the login page. An operator with database access can create an account, grant the admin role, and mint an admin session directly:
# Create the account and grant the admin role
systemprompt admin users create --name "Jane" --email jane@customertimes.com
systemprompt admin users role promote jane@customertimes.com admin
# Mint a CLI admin session against the instance database
systemprompt admin session switch production # or the profile you are recovering
systemprompt admin session login
systemprompt admin users webauthn generate-setup-token issues a fresh
passkey-enrolment token for an existing account, and
systemprompt admin bridge issue-code mints a device-link code headlessly.
These, and a personal access token minted from an existing admin session, are
the platform's break-glass credentials: every one of them needs database access
or a session that already exists.
Desktop bridge device link
Linking the desktop bridge reuses the browser session rather than asking for a second credential:
- The bridge opens
/bridge-auth/device-linkin your browser. If you are not signed in you are sent to/admin/loginfirst and return afterwards. - The page shows the device and asks you to approve or deny it.
- On approval the platform mints a bridge credential (a personal access token and a device certificate) bound to your account and hands it back to the bridge. The link code is single use with a 10-minute TTL.
Headless or SSH installs use customertimes-bridge login --no-browser with a
code from your profile page or from systemprompt admin bridge issue-code.
See Install the Desktop Bridge.
Revocation
An admin disables or deletes a user from /admin/access/users, or revokes
their sessions and tokens from the user's detail page. A refused login also
tears down what the account already held. In each case the platform revokes
the user's:
- browser sessions (
user_sessions), - bridge PATs (
user_api_keys) — the desktop app must be linked again, - enrolled device certificates,
- any unredeemed bridge device-link code.
Rows are marked revoked, never deleted: what someone held and when it was taken away is part of the audit trail.
A gateway token already minted stays valid for its hour. Revocation stops the next one, so treat "the bridge is dead" as true within the hour, not within the second. For an immediate cut, disable the account.
Session Management
| Property | Value |
|---|---|
| Cookie names | access_token, refresh_token |
| Token format | JWT |
| Default expiry | 3600 seconds (1 hour) for the access token |
| Cookie flags | path=/, HttpOnly, SameSite=Lax, Secure on HTTPS |
| Required scopes | user or admin |
Every admin request passes through two middleware layers. User context
middleware extracts and validates the JWT, then loads the user's roles and
project into a UserContext. Auth check middleware rejects protected
routes without a valid user ID, returning HTTP 401.
UserContext carries user_id, username, email, roles, project, and
is_admin.
To sign out, clear the session cookies; the login page does this when it starts a new flow.
Public vs. Protected Routes
| Route | Access |
|---|---|
/admin/login |
Public |
/admin/register, /admin/api/register |
Public — open registration with a chosen role; closed by allow_registration: false |
/admin/add-passkey, /admin/verify-pending |
Public — passkey enrolment with a setup token |
/admin/api/magic-link/* |
Public — recovery request and validate |
/admin/profile, /admin/settings, /admin/setup |
Any valid session, including a plain user |
/admin/* (everything else) |
Requires a valid session and the admin role |
/admin/enterprises*, /admin/reports/internal |
Requires platform admin |
/bridge-auth/* |
Requires a valid session — the device link runs inside it |
Anonymous requests to a protected route are redirected to
/admin/login?redirect=…. A signed-in user without the admin role is not
shown a 403 for console pages — they are redirected to /admin/profile, which
is the only part of the console addressed to them. JSON admin API routes return
HTTP 403 instead, and the platform-admin routes return an HTML 403.
System-originated actions
Every action recorded by the platform — including scheduled jobs, hooks, and MCP-server invocations — traces to a real users row. There is no separate "system user" or synthesized principal. The platform refuses to attribute work to an invented identity.
How ownership is declared
Each scheduled job in services/scheduler/config.yaml carries an explicit owner: field naming an existing admin user:
- name: publish_pipeline
extension: web
owner: admin
schedule: "0 */15 * * * *"
enabled: true
At startup the scheduler resolves owner: to a users.id. If the named user does not exist or is inactive, startup fails loudly — the platform refuses to run with unowned jobs. To change ownership, edit the YAML and restart.
How attribution flows
The resolved owner becomes JobContext.actor for every execute() call. Job implementations consume it through ctx.actor() and pass it to any audit-row write. Governance audit rows carry three fields that together give full forensic clarity:
| Column | Meaning |
|---|---|
user_id |
The accountable principal — a real users.id. |
actor_kind |
The surface that ran the action: user, job, mcp. |
actor_id |
A label for that surface (job name, MCP server name, etc.). |
A direct human action shows as (user_id = alice, actor_kind = 'user', actor_id = 'alice'). A scheduled job owned by Alice shows as (user_id = alice, actor_kind = 'job', actor_id = 'publish_pipeline'). Same accountability column, different surface, queryable separately:
SELECT actor_kind, user_id, COUNT(*)
FROM governance_decisions
GROUP BY actor_kind, user_id;
Why no separate "system" user
A dedicated "system" identity would be either a synthesized principal (impersonation) or a backdoor account with no real human accountability. Neither passes the "every action traces to a real user" bar. The designated owner is a normal admin who legitimately authorized the platform's existence by installing it — same accountability model as a unix crontab. Compromising the designated owner is exactly as bad as compromising that admin's credentials directly; there is no additional power and no amplification path.