This document was written by AI and has been manually reviewed.
Prism is a standards-compliant OAuth 2.0 authorization server and OpenID Connect provider. Any application that supports OAuth 2.0 authorization code flow can use Prism as its identity provider.
Discovery
Prism publishes its metadata at these .well-known locations (RFC 8615):
https://your-prism-domain/.well-known/openid-configuration # OpenID Connect Discovery 1.0
https://your-prism-domain/.well-known/oauth-authorization-server # RFC 8414
https://your-prism-domain/.well-known/oauth-protected-resource # RFC 9728
https://your-prism-domain/.well-known/jwks.json # signing keysThe first two describe the authorization server (same endpoints); most OAuth/OIDC libraries auto-configure from either. oauth-protected-resource (RFC 9728) describes Prism's API as a protected resource — which authorization server issues its tokens, the scopes it recognises, and its DPoP support. A 401 from a protected endpoint points here via WWW-Authenticate: ... resource_metadata="…".
Issuer discovery (WebFinger, RFC 7033)
A client that starts from a user identifier can discover the issuer:
GET /.well-known/webfinger?resource=acct:alice@your-prism-domain&rel=http://openid.net/specs/connect/1.0/issuerreturns a JRD (application/jrd+json) linking to the issuer:
{
"subject": "acct:alice@your-prism-domain",
"links": [
{
"rel": "http://openid.net/specs/connect/1.0/issuer",
"href": "https://your-prism-domain"
}
]
}Registering an application
- Log in to Prism and go to Apps → New Application
- Fill in the name, description, and redirect URIs
- Copy the Client ID and Client Secret — the secret is shown only once
If your app runs entirely in the browser (no server to keep the secret), enable Public client. Public clients must use PKCE and do not have a client secret.
Redirect URI matching
Each registered redirect URI carries a match type:
| Type | Behaviour |
|---|---|
Equals | Exact match after URL normalization (default; the safest option). |
Wildcard | A glob where * stands in for any run of characters, e.g. https://example.com/*. |
Regex | A regular expression matched against the whole candidate URI, e.g. https://example\.com/.*. |
Every candidate is first passed through a safety gate regardless of type: the scheme must be https: (or http: for loopback hosts), and the URI must not carry userinfo (user:pass@…) or a fragment (#…).
Empty list (learn-first-used). If you leave the redirect URI list empty, the app "learns" the first redirect URI it is successfully used with, pins it as an Equals entry, and locks the app to that value going forward.
WARNING
A Regex value of .* allows any redirect URI, including attacker-controlled ones. Only use it if you fully understand the security implications.
Authorization code flow (with PKCE)
Step 1 — Redirect the user
GET https://your-prism-domain/api/oauth/authorize
?response_type=code
&client_id=<CLIENT_ID>
&redirect_uri=https://yourapp.com/callback
&scope=openid profile email
&state=<RANDOM_STATE>
&code_challenge=<CODE_CHALLENGE>
&code_challenge_method=S256PKCE — generate a code_verifier (43–128 random URL-safe characters), then:
code_challenge = BASE64URL(SHA-256(ASCII(code_verifier)))Scopes
| Scope | Claims / access granted |
|---|---|
openid | sub, iss, aud, iat, exp (required for OIDC) |
profile | name, preferred_username, picture |
profile:write | Update the user's profile (name, picture) |
email | email, email_verified |
apps:read | List of apps the user owns |
apps:write | Create, update, and delete the user's apps |
teams:read | List the user's teams |
teams:write | Update team settings and manage members |
teams:create | Create new teams |
teams:delete | Delete teams |
domains:read | List the user's custom domains |
domains:write | Add and remove custom domains |
gpg:read | List the user's registered GPG public keys |
gpg:write | Add and remove GPG public keys |
social:read | List the user's linked social provider accounts |
social:write | Disconnect social provider accounts |
admin:users:read | Read all user accounts (admin only) |
admin:users:write | Modify user accounts (admin only) |
admin:users:delete | Delete user accounts (admin only) |
admin:config:read | Read instance configuration (admin only) |
admin:config:write | Update instance configuration (admin only) |
admin:invites:read | List invitations (admin only) |
admin:invites:create | Create invitations (admin only) |
admin:invites:delete | Delete invitations (admin only) |
offline_access | Enables refresh token issuance |
Team scopes — three tiers
Three scope families touch teams, with very different blast radius. Pick the narrowest one that fits.
| Tier | Example | Scope of access | Granted by |
|---|---|---|---|
| Aggregate (plural) | teams:read | Every team the user is a member of | Normal user consent |
| Single-team (singular) | team:read | Exactly one team, picked at consent time | Normal user consent + team-id picker (admin+ on that team) |
| Cross-instance | site:team:read | Every team on the instance | Admin-only, requires 2FA + confirmation phrase |
Aggregate teams:*
teams:read teams:write teams:create teams:deleteActs across the user's whole team graph. One consent grants access to all of them. Right shape for an app that wants to reflect or sync the user's membership list — e.g. an OIDC IdP that needs the teams claim for Cloudflare Access policies, or a workspace switcher that lists every team the user belongs to.
Endpoints sit under /api/oauth/me/teams[/...].
Single-team team:*
team:read team:member:read
team:write team:member:write
team:delete team:member:profile:readThese are the scope strings the app requests. At consent time the user picks one specific team and Prism rewrites them in place — team:read becomes team:<team-id>:read — and only that bound form lives in the issued token. The token can only act on that one team.
Two extra rules at consent time (worker/routes/oauth.ts:830-859):
- The user must be
owner,co-owner, oradminof the chosen team. Effective role counts — an admin inherited from an ancestor team can grant single-team scopes on a sub-team (matching the session API). The consent screen's team picker shows every team the user can manage, direct or inherited. team:deleteadditionally requiresownerorco-owner. (Admins can grant reads/writes; only the people who could actually disband the team can grant the deletion.) Same effective-role rule applies — an inherited owner can grantteam:deleteon a sub-team and the recursivedissolveTeamcascade carries it through.
team:member:write also can't escalate beyond what the granting user could do themselves: an admin granting team:member:write cannot give the app the ability to promote members past admin — the cap is checked on every member mutation.
Each grant is audited in the team_scope_grants table with the team id and permission list, separate from the OAuth consent record.
Endpoints sit under /api/oauth/me/team/:teamId/.... They check the bound token via resolveTeamToken(c, teamId, "read"|"write"|"member:read"|...), so passing a token bound to team A while calling the team-B endpoint returns 403 insufficient_scope.
Cross-instance site:team:*
site:team:read site:team:write site:team:deleteCross-team admin access without a per-team consent. Granting these requires the consenting user to be a site admin and goes through the site-scope confirmation flow — 2FA plus typing the exact phrase grant site access. Use them only for site-administration tools that genuinely need to see or touch every team.
Picking a tier — quick rules
- Use
teams:*for "what teams is this user in?" use cases. Useteam:*if your integration scopes to a single team (e.g. a deploy bot for one workspace). - Don't request
teams:*andteam:*together — you'll get the union, but the consent UX is confusing because users get both a team-id picker and an all-teams notice on the same screen. site:team:*is for site-administration tooling, not for product integrations. Anything granted there bypasses team owners' consent.
Site scopes (admin-only)
The full list of cross-instance scopes; same admin-only / 2FA / confirmation phrase gate as site:team:*:
| Scope | Grants |
|---|---|
site:user:read | Read every user account |
site:user:write | Modify any user |
site:user:delete | Delete any user |
site:team:read | Read every team |
site:team:write | Modify any team |
site:team:delete | Disband any team |
site:config:read | Read site config |
site:config:write | Modify site config |
site:token:revoke | Revoke any user's OAuth tokens |
Step 2 — User consents
Prism shows a consent screen listing your app name and the requested scopes. If the user has already consented to the same scopes, the consent screen is skipped automatically.
Step 3 — Receive the code
Prism redirects to your redirect_uri:
https://yourapp.com/callback?code=<AUTH_CODE>&state=<STATE>Always verify that state matches what you sent.
Step 4 — Exchange for tokens
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<AUTH_CODE>
&redirect_uri=https://yourapp.com/callback
&client_id=<CLIENT_ID>
&client_secret=<CLIENT_SECRET>
&code_verifier=<CODE_VERIFIER>Public clients omit client_secret and must include code_verifier.
Response
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"id_token": "...",
"scope": "openid profile email"
}Step 5 — Call UserInfo
GET /api/oauth/userinfo
Authorization: Bearer <ACCESS_TOKEN>The endpoint accepts both GET and POST (OpenID Connect Core §5.3.1). The access token must carry the openid scope; a token without it is refused with 403 insufficient_scope. A rejected request returns a WWW-Authenticate: Bearer challenge per RFC 6750.
UserInfo response
{
"sub": "user-id",
"name": "Alice",
"preferred_username": "alice",
"email": "alice@example.com",
"email_verified": true,
"picture": "https://your-prism-domain/api/assets/avatars/..."
}Refreshing tokens
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=<REFRESH_TOKEN>
&client_id=<CLIENT_ID>
&client_secret=<CLIENT_SECRET>The response carries a new refresh_token; store it in place of the one you sent. Refresh tokens rotate on every use, and presenting a superseded one revokes the whole grant — whether it was replayed by a client that kept the old value or by someone who stole it, the safe reading is the same. The new token inherits the original expiry: rotating does not extend the grant.
Token introspection (RFC 7662)
For server-to-server verification without parsing JWTs:
POST /api/oauth/introspect
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>
token=<ACCESS_TOKEN>Client credentials are required, and a client can only introspect tokens that were issued to it — anything else answers {"active": false}.
Response (active token)
{
"active": true,
"sub": "user-id",
"scope": "openid profile",
"client_id": "...",
"token_type": "Bearer",
"exp": 1234567890,
"iat": 1234564290,
"aud": "...",
"iss": "https://your-prism-domain"
}Token revocation (RFC 7009)
POST /api/oauth/revoke
Content-Type: application/x-www-form-urlencoded
token=<ACCESS_OR_REFRESH_TOKEN>
&client_id=<CLIENT_ID>
&client_secret=<CLIENT_SECRET>Client credentials are required, and only the calling client's own tokens are revoked. A superseded refresh token revokes the grant it belonged to.
Device Authorization Grant (RFC 8628)
For input-constrained devices (CLIs, TVs, IoT) that can't host a browser.
POST /api/oauth/device_authorization
Content-Type: application/x-www-form-urlencoded
client_id=<CLIENT_ID>
&scope=openid profileResponse:
{
"device_code": "…",
"user_code": "WDJB-MJHT",
"verification_uri": "https://your-prism-domain/device",
"verification_uri_complete": "https://your-prism-domain/device?user_code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}Show the user verification_uri and user_code (or the QR-friendly verification_uri_complete). Meanwhile, poll the token endpoint:
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=<DEVICE_CODE>
&client_id=<CLIENT_ID>Until the user acts, the endpoint returns authorization_pending (or slow_down if you poll faster than interval); poll no faster than interval seconds. Once approved it returns the usual token response (with id_token when openid was requested, and a refresh_token when offline_access was). access_denied and expired_token are terminal. PKCE is optional: include a code_challenge in the device-authorization request and the matching code_verifier when polling. Site-level and team scopes cannot be granted through the device flow.
Dynamic Client Registration (RFC 7591 / 7592)
Register a client programmatically. The request must carry an initial access token — a signed-in user's session token or a personal access token with apps:write:
POST /api/oauth/register
Authorization: Bearer <SESSION_OR_PAT>
Content-Type: application/json
{
"client_name": "My CLI",
"redirect_uris": ["https://app.example.com/callback"],
"scope": "openid profile email",
"token_endpoint_auth_method": "client_secret_basic"
}The 201 response is the client information document: client_id, client_secret (for confidential clients), a registration_access_token, and registration_client_uri. Manage the client afterwards at that URI (RFC 7592): GET reads it, PUT updates it, DELETE deregisters it — each authenticated with Authorization: Bearer <registration_access_token>. To register a private_key_jwt client, set token_endpoint_auth_method to private_key_jwt and include jwks (an inline JWK Set) or jwks_uri.
private_key_jwt client authentication (RFC 7523)
A confidential client may authenticate with a signed assertion instead of a shared secret. Register the client's public keys and select the auth method — either in the dashboard (App Detail → Settings: set Token endpoint auth method to private_key_jwt and paste an inline jwks or a jwks_uri), via DCR metadata, or through the app API. Then at the token / PAR / introspection / revocation endpoints send:
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<JWT>The assertion is a JWT with iss = sub = your client_id, aud = the issuer or token endpoint URL, a short exp, and a unique jti (one-time use). Supported signing algorithms: RS256, ES256, EdDSA.
DPoP — sender-constrained tokens (RFC 9449)
Bind a token to a key the client holds, so a stolen token value is useless without the key. On the token request, send a DPoP header — a JWT signed by the client's key, carrying the public key in its header and bound to the request:
POST /api/oauth/token
DPoP: <proof-jwt> # htm=POST, htu=<token endpoint>, iat, jti
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=...&client_id=...&code_verifier=...The response comes back "token_type": "DPoP" and the access token is bound to the key's thumbprint (cnf.jkt). At a resource, present it with the DPoP scheme and a fresh proof that also hashes the token (ath):
GET /api/oauth/userinfo
Authorization: DPoP <ACCESS_TOKEN>
DPoP: <proof-jwt> # htm=GET, htu=<resource url>, ath=base64url(sha256(token))A DPoP-bound token presented as plain Bearer, or without a matching proof, is rejected. Refresh requests must repeat the proof from the same key. Supported proof algorithms: RS256, ES256, EdDSA.
Token Exchange (RFC 8693)
Exchange one access token for another — for delegation between apps:
POST /api/oauth/token
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<ACCESS_TOKEN>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&scope=openid profile
&resource=https://api.example.comThe requesting client may exchange a token that was issued to it, or one that carries a cross-app scope naming it (app:<client_id>:*). The new token's scope is a subset of the subject token's, and its audience is constrained by resource / audience. The response includes issued_token_type: urn:ietf:params:oauth:token-type:access_token. Exchanged tokens are not refreshable.
Re-authentication and context (prompt, max_age, acr)
The authorization request honors the OpenID Connect parameters:
prompt=none— no UI; if the user isn't signed in (or must re-authenticate) the client getslogin_required, and if consent is missing,consent_required.prompt=login— force a fresh sign-in even if a session exists.prompt=consent— always show the consent screen.max_age=<seconds>— require a sign-in no older than this, else re-authenticate.
The ID token then carries auth_time (when the user signed in), amr (the authentication methods, e.g. ["pwd","otp","mfa"], ["webauthn"], ["ext"]), and a derived acr (mfa when a second factor was used, else pwd).
Step-up authentication (RFC 9470)
Request a specific context with acr_values on the authorization request (e.g. acr_values=mfa); if the current session doesn't meet it, Prism re-authenticates so a stronger factor can raise it. Access tokens (and the introspection response) carry acr / auth_time / amr, so a resource server can require a stronger authentication and answer a request that falls short with:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="mfa"The client then repeats authorization with acr_values=mfa.
Pushed Authorization Requests (RFC 9126)
Push the authorization parameters to the server first and receive a one-time request_uri to use at the authorize endpoint — the request can't be tampered with in the browser, and secrets never ride in the front channel.
POST /api/oauth/par
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>
response_type=code
&redirect_uri=<REDIRECT_URI>
&scope=openid profile
&code_challenge=<CHALLENGE>&code_challenge_method=S256
&state=<STATE>Response (201 Created):
{ "request_uri": "urn:ietf:params:oauth:request_uri:…", "expires_in": 90 }Then send the user to the authorize endpoint with just the client and request URI:
https://your-prism-domain/api/oauth/authorize?client_id=<CLIENT_ID>&request_uri=<REQUEST_URI>The request_uri is single-use and short-lived.
Authorization response iss (RFC 9207)
Every authorization response (success and error) carries an iss parameter set to your Prism instance URL. Clients that validate it are protected against mix-up attacks. Discovery advertises authorization_response_iss_parameter_supported: true.
Resource Indicators (RFC 8707)
Add one or more resource parameters (absolute URIs, no fragment) to a /par, authorization, or /device_authorization request to name the resource server(s) the access token is for. Each accepted value is added to the token's aud, and is preserved across refreshes.
RP-Initiated Logout (OpenID Connect)
GET /api/oauth/end_session?id_token_hint=<ID_TOKEN>&post_logout_redirect_uri=<URI>&state=<STATE>Ends the user's Prism session and clears the session cookie. When post_logout_redirect_uri exactly matches one registered on the client (via the app's post_logout_redirect_uris), the browser is sent there with state; otherwise it lands on Prism's built-in signed-out page. id_token_hint identifies the client (an expired hint is still accepted) and is recommended.
Back-Channel Logout (OpenID Connect)
Register a backchannel_logout_uri on your client (App Detail → Settings, the DCR metadata, or the app API). When the user signs out of Prism — via end_session or the dashboard — Prism POSTs a signed logout_token to that URI:
POST <backchannel_logout_uri>
Content-Type: application/x-www-form-urlencoded
logout_token=<JWT>The logout_token is an RS256 JWT (typ: logout+jwt) with iss, aud (your client_id), sub, iat, jti, a sid (the session that ended, also present as sid in the ID token), and the back-channel-logout events claim. Verify it against the JWKS and terminate the user's session. Discovery advertises backchannel_logout_supported and backchannel_logout_session_supported.
ID token
The ID token is a signed JWT. The default algorithm is ML-DSA-65 (post-quantum, FIPS 204); RS256 is also published at /.well-known/jwks.json for legacy clients. Verify it using the public key from the JWKS endpoint, or use the introspection endpoint for server-side validation without parsing JWTs.
Standard claims (always present when openid scope is requested):
| Claim | Value |
|---|---|
iss | Your Prism instance URL |
sub | Stable user ID |
aud | Your client_id |
iat | Issued-at timestamp |
exp | Expiry timestamp |
role | User role (user or admin) |
nonce | Echoed from authorization request |
Scope-gated claims — profile and email claims are included whenever the corresponding scope is granted. The remaining claims below also require the application to declare the field name in its oidc_fields configuration:
| Scope | Field name | Claim(s) added to ID token |
|---|---|---|
profile | (always) | name, preferred_username, picture |
email | (always) | email, email_verified |
teams:read | teams | teams — array of { id, name, role, groups } objects for the user's team memberships |
apps:read | apps | apps — array of { id, name, client_id, is_verified } objects for the user's apps |
domains:read | domains | domains — array of { id, domain, verified } objects |
gpg:read | gpg_keys | gpg_keys — array of { id, fingerprint, key_id, name } objects |
social:read | social_accounts | social_accounts — array of { id, provider, provider_user_id } objects |
To opt an application into a custom claim, include the field name in the app's oidc_fields array when creating or updating it via the API:
{ "oidc_fields": ["teams", "domains"] }Flat per-team claims
Independent of oidc_fields, granting teams:read — or any bound team:<id>:* scope — always emits flat per-team markers, because policy engines like Cloudflare Access can only match on flat claim names:
| Claim | Value | Emitted when |
|---|---|---|
in_team_<team-id> | true | The user is a member of that team |
role_in_team_<team-id> | e.g. admin | Always, alongside in_team_<team-id> |
groups_in_team_<team-id> | array of slugs | The team uses member groups and the user holds at least one |
groups_in_team_<id> is omitted rather than sent empty — absence already means "holds no group here", and skipping it keeps the token from growing a claim per team. It is also absent for any team with the groups feature switched off, regardless of what is stored.
The values are group slugs, not display names: slugs are immutable, so a policy that matches on them keeps working after a team renames a group.
Step-up 2FA
Apps can ask Prism to have the user re-confirm with TOTP or passkey before performing a sensitive action — wire transfers, deleting resources, granting elevated access, etc.
The flow is server-initiated: your server registers the action with Prism over HTTPS first, and only then redirects the user. The action text and redirect URI are pinned at the server-to-server step, so an attacker who only controls a URL cannot forge a confirmation page that says whatever they want.
The user must be logged into Prism (they're redirected to login if not) and have a TOTP authenticator or passkey enrolled. No new account access is granted — the result is a one-time proof that the user re-confirmed.
Step 1 — Create a challenge (server-to-server)
POST /api/oauth/2fa/challenges
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/json
{
"redirect_uri": "https://app.example.com/2fa-callback",
"action": "Confirm wire transfer of $1,000",
"nonce": "order_abc123",
"code_challenge": "PKCE_CHALLENGE",
"code_challenge_method": "S256"
}| Field | Required | Description |
|---|---|---|
client_id | yes (in Basic or body) | Your OAuth app's client ID |
client_secret | confidential clients | In Basic auth or body |
redirect_uri | yes | Must be registered on the OAuth app |
action | recommended | Human-readable description (≤ 200 chars) of what the user is confirming. Shown verbatim on the Prism page and echoed in the verify response |
nonce | optional | App-defined opaque value (≤ 256 chars), echoed back. Bind it to the operation (e.g. an order ID) |
code_challenge, code_challenge_method | required for public clients | PKCE — see Authorization Code flow |
Response
{
"challenge_id": "f3a…opaque…",
"expires_at": 1761500900,
"url": "https://prism.example.com/oauth/2fa?challenge_id=f3a…"
}Public clients (no client_secret) authenticate this call with PKCE — they pass code_challenge here and code_verifier at verify time. The server rate-limits challenge creation per client (60/min) so a leaked secret can't be used to spam users.
Step 2 — Redirect the user
https://prism.example.com/oauth/2fa?challenge_id=f3a…&state=RANDOMThe URL contains only the opaque challenge_id and your CSRF state. There's nothing else for an attacker to tamper with.
Step 3 — User confirms
Prism shows the app icon, the verified-domain badge if applicable, the action text from the challenge, and prompts for TOTP or passkey. The user must also tick a checkbox echoing the action ("I have read and understand: …") before the Confirm button enables.
The user clicks Confirm or Deny.
Step 4 — Receive the code
Prism redirects the user back to the challenge's pinned redirect_uri:
https://app.example.com/2fa-callback?code=…&state=…Or, on denial / error:
https://app.example.com/2fa-callback?error=access_denied&state=…Step 5 — Verify (server-side)
POST /api/oauth/2fa/verify
Content-Type: application/x-www-form-urlencoded
code=THE_CODE&client_id=YOUR_CLIENT_ID&redirect_uri=…&code_verifier=PKCE_VERIFIERConfidential clients send client_secret in the body or via HTTP Basic. Public clients use PKCE only.
Response
{
"user_id": "u_abc",
"client_id": "YOUR_CLIENT_ID",
"verified_at": 1761500000,
"action": "Confirm wire transfer of $1,000",
"nonce": "order_abc123",
"method": "totp"
}The code is single-use and expires 5 minutes after issuance. After successful verification:
verified_atis the unix timestamp the user completed 2FA — treat anything older than your acceptable window as stale.- Compare
nonceandactionagainst what your app stored when it built the URL — if they don't match, reject the result. methodis"totp","passkey", or"backup".
Captcha gate
Sites can require users to solve a captcha before they can approve a 2FA step-up. There are two ways the gate is triggered:
- Site default — admins toggle
require_captcha_for_2fato demand a captcha for every step-up site-wide. - Per-app opt-in — apps include
require_captcha: truewhen they callPOST /api/oauth/2fa/challenges. Useful for apps that want extra friction on their high-stakes actions even when the site default is off. (Apps cannot disable an enforced site-wide gate.)
The site's already-configured captcha provider is used (Turnstile, hCaptcha, reCAPTCHA, or PoW). If captcha_provider is "none", the gate is a no-op even when one of the triggers fires.
The user-facing /api/oauth/2fa/info response surfaces captcha_required, captcha_provider, and captcha_site_key so the SPA can render the right widget. The user solves the challenge and submits captcha_token (or pow_challenge + pow_nonce) along with their TOTP/passkey to /api/oauth/2fa/authorize.
The captcha gate is skipped on the sudo bypass path: with no factor being checked there's no anti-bot surface, and forcing a solve would defeat the point of the sudo grace window.
Sudo mode (grace window)
After a successful TOTP/passkey confirmation, the user can opt into a sudo grace window during which subsequent challenges from the same app on the same Prism session bypass the 2FA prompt. The action acknowledgment checkbox is still required, so the user always sees and confirms what they're approving — only the TOTP/passkey re-prompting is skipped.
The TTL is admin-configured via the sudo_mode_ttl_minutes site setting. Set it to 0 to disable sudo mode entirely.
The grant is bound to the tuple (user_id, session_id, client_id) — it does not leak across apps, sessions, or users. Logging out of Prism rotates the session ID, so all sudo grants for that session become unreachable.
When the user opts in, Prism returns the redirect with a code whose method field is "sudo". Apps performing very high-stakes operations (account deletion, large transfers) should require method !== "sudo" so those particular actions always trigger a fresh 2FA prompt.
Revoking a sudo window
Users can drop a sudo window ahead of its TTL:
POST /api/oauth/2fa/sudo/revoke
Authorization: Bearer <user-session-jwt>
Content-Type: application/json
{ "client_id": "YOUR_CLIENT_ID" }Threat model
What this defends against:
- URL-only phishing. An attacker who can only craft a URL (e.g. via a phishing email) cannot inject arbitrary action text or pick an arbitrary redirect URI. Both are pinned server-side at Step 1, which the attacker cannot reach without the app's
client_secret(or, for public clients, without compromising the app itself). - Code interception. PKCE binds the code to the verifier; the code is also bound to
(client_id, redirect_uri). Even if the code leaks (e.g. via referrer), it cannot be redeemed by another app or to a different URI. - TOTP brute force. Per-user rate limit of 8 attempts per 5 min. A failed attempt also burns the challenge — the attacker has to round-trip a fresh server-initiated POST to retry.
- Replay / double-redemption. The challenge and the resulting code are each consumed atomically (
UPDATE … WHERE consumed_at IS NULL). - Blind clicking. The Confirm button stays disabled until the user explicitly ticks a checkbox echoing the action text.
- UI spoofing.
action,nonce, andstateare length-capped, so a malicious app cannot smuggle a giant blob into the consent UI.
What it does not defend against:
- A fully compromised device (malware can read TOTP codes off the screen and exfiltrate session cookies — no auth flow can save you here).
- An attacker with the user's
client_secretwho is also authorized to act as the app — they can mint legitimate challenges. Rotate secrets if you suspect compromise.
Error responses
Authorization errors redirect to your redirect_uri with:
?error=access_denied&error_description=User+denied+accessToken endpoint errors return HTTP 400:
{ "error": "invalid_grant", "error_description": "Code expired or invalid" }Common error codes: invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, access_denied.
Integrations
Cloudflare Access
You can use Prism as a generic OIDC identity provider for Cloudflare Access, allowing users to authenticate to Cloudflare-protected resources with their Prism account.
Step 1 — Create an OAuth app in Prism
Log in to Prism and go to Apps → New Application
Set the redirect URI to:
texthttps://<your-team-name>.cloudflareaccess.com/cdn-cgi/access/callbackSet Allowed scopes to include at minimum
openidandemail. Addprofile,teams:read, etc. if you need those claims in Access policies.Set OIDC fields to the custom claims you want embedded in the ID token, e.g.
["role", "teams"]. This controls which scope-gated claims Prism includes.Copy the Client ID and Client Secret.
Step 2 — Add Prism as an identity provider in Cloudflare
In Cloudflare Zero Trust, go to Integrations → Identity providers → Add new → OpenID Connect and fill in:
| Field | Value |
|---|---|
| Name | Prism (or any label) |
| App ID | Your Prism Client ID |
| Client secret | Your Prism Client Secret |
| Auth URL | https://your-prism-domain/api/oauth/authorize |
| Token URL | https://your-prism-domain/api/oauth/token |
| Certificate URL | https://your-prism-domain/.well-known/jwks.json |
| PKCE | Enabled (recommended) |
| Scopes | openid email (add profile teams:read etc. as needed) |
| OIDC Claims | One per line — the claim names you want usable in policies |
Under OIDC Claims, enter the names of the custom claims Prism returns, for example:
role
in_team_<team-id>
role_in_team_<team-id>
groups_in_team_<team-id>After saving, use Test to verify. A successful test shows the claims under oidc_fields:
{
"email": "alice@example.com",
"oidc_fields": {
"role": "admin",
"in_team_abc123": true,
"role_in_team_abc123": "owner",
"groups_in_team_abc123": ["backend", "oncall"]
}
}Step 3 — Build Access policies using Prism claims
In your Access application policy, use the OIDC Claim selector:
| Selector | Claim name | Claim value | Effect |
|---|---|---|---|
| OIDC Claim | role | admin | Prism admins only |
| OIDC Claim | in_team_<team-id> | true | Members of a specific team |
| OIDC Claim | role_in_team_<team-id> | owner | Team owners only |
| OIDC Claim | groups_in_team_<team-id> | oncall | Members holding that group |
Note: Cloudflare Access reads custom claims from the ID token (RS256-signed JWT). The claim names listed under OIDC Claims in the dashboard must exactly match what Prism embeds in the token, which is controlled by the app's
oidc_fieldssetting.
Matching an array claim:
groups_in_team_<team-id>is an array (e.g.["backend", "oncall"]). Access matches it through its Multi-record OIDC claims support — each element is parsed out individually, so a policy value ofoncallmatches any user whose array containsoncall. Matching is on the whole element only: Access does not support partial/substring value references, which is exactly why the claim carries immutable group slugs rather than display names.