Authentication
Understand how systemprompt.io authenticates users via OAuth 2.0 with PKCE, session cookies, magic links, and the add-passkey onboarding flow.
On this page
TL;DR: systemprompt.io uses OAuth 2.0 with PKCE for secure, passwordless authentication. When you visit the login page you are automatically redirected to the OAuth authorization flow, and upon success a JWT access token is stored as a session cookie. Magic links provide an email-based alternative for marketplace access, and the add-passkey page lets new users register their credentials.
What You'll See
When you navigate to /admin/login, the login page displays a brief "Redirecting to sign in..." message with a loading spinner. There is no username/password form -- the page immediately begins the OAuth authorization flow in the background. If something goes wrong, an error message appears along with a "Try again" link.
The page has three visual states:
| State | What You See |
|---|---|
| Loading | A spinner with "Processing..." while the OAuth flow executes |
| Error | A red error message describing what went wrong |
| Retry | A "Try again" link that restarts the login flow |
OAuth 2.0 + PKCE Flow
The login page implements a full OAuth 2.0 Authorization Code flow with Proof Key for Code Exchange (PKCE). This is the primary authentication method for the admin dashboard.
How It Works
- Start -- The login page generates a random PKCE code verifier (64 characters) and computes a SHA-256 code challenge. Both are stored in
sessionStorage. - Authorize -- The browser redirects to
/api/v1/core/oauth/authorizewith the code challenge, a CSRF state parameter, client ID (marketplace-admin), and scope (user). - User authenticates -- The OAuth provider handles the actual credential verification (this is delegated to the configured identity provider).
- Callback -- The OAuth provider redirects back to
/admin/login?code=.... The login page extracts the authorization code from the URL. - Token exchange -- The page sends the authorization code and the original PKCE code verifier to exchange for tokens.
- Session created -- The returned JWT access token is stored as an
access_tokencookie, and a server-side session is established. - Redirect -- The user is sent to the admin dashboard (or to a custom redirect URL if one was specified via the
?redirect=query parameter).
Token Details
| Property | Value |
|---|---|
| Cookie name | access_token |
| Token format | JWT (JSON Web Token) |
| Default expiry | 3600 seconds (1 hour), configurable via OAuth response |
| Cookie flags | path=/, SameSite=Lax, Secure (HTTPS only) |
| Required scopes | user or admin |
The JWT payload includes the user ID, username, email, scope, and expiration timestamp. The token is validated server-side on every request using the configured JWT secret and issuer.
Automatic Token Check
If you visit /admin/login while already holding a valid access token, the page skips the OAuth flow and redirects you directly to the dashboard. The token is validated client-side by checking the JWT expiry and scope before redirecting.
Session Management
Once authenticated, every request to the admin dashboard passes through two middleware layers:
- User context middleware -- Extracts the JWT from the
access_tokencookie, validates it, and loads the user's roles and department from the database. This information is made available to all page handlers as aUserContext. - Auth check middleware -- For protected routes, verifies that a valid (non-empty) user ID exists. Returns HTTP 401 if not authenticated.
The UserContext contains:
| Field | Description |
|---|---|
user_id |
Unique user identifier |
username |
Display name |
email |
User email address |
roles |
Array of assigned roles (e.g., admin, developer, analyst, viewer) |
department |
User's department |
is_admin |
Whether the user has the admin role |
Signing Out
To sign out, clear the access_token cookie. The login page does this automatically when starting a new OAuth flow.
Magic Link Authentication
Magic links provide an email-based authentication alternative, primarily used for marketplace access. Instead of going through the OAuth flow, users receive a one-time link via email.
How Magic Links Work
- Request -- A POST request is sent to the magic link endpoint with the user's email address.
- Token generation -- If the email belongs to an existing user, a random 32-byte token is generated and its SHA-256 hash is stored in the database with a 15-minute expiry.
- Email delivery -- The raw token is included in a link sent to the user's email.
- Validation -- When the user clicks the link, the token is validated and consumed (single-use). The response includes the user's email for session establishment.
Rate Limiting
Magic link requests are rate-limited to 3 tokens per email address within a 15-minute window. Requests beyond this limit return a success response (to prevent email enumeration) but do not generate new tokens.
Security Properties
| Property | Detail |
|---|---|
| Token length | 32 random bytes (64 hex characters) |
| Storage | SHA-256 hash only (raw token never stored) |
| Expiry | 15 minutes from creation |
| Usage | Single-use (consumed on validation) |
| Enumeration protection | Same response regardless of whether the email exists |
Add Passkey
The add-passkey page (/admin/add-passkey) is a public route (no authentication required) used during user onboarding to register a new passkey or credential. This page is accessible without an existing session, allowing invited users to set up their authentication method before their first login.
The add-passkey route is one of only two public admin routes -- the other being the login page itself. All other admin pages require an authenticated session.
Public vs. Protected Routes
The admin dashboard enforces authentication at the routing level:
| Route | Access |
|---|---|
/admin/login |
Public -- no authentication required |
/admin/add-passkey |
Public -- no authentication required |
/admin/* (all other pages) |
Requires valid session (JWT cookie) |
/admin/api/* (write operations) |
Requires valid session + admin role for admin-only endpoints |
Pages that require admin privileges (such as Access Control and user management) perform an additional is_admin check and return HTTP 403 with an "Admin access required" message if the user lacks the admin role.
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.