Skip to content

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):

text
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 keys

The 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:

text
GET /.well-known/webfinger?resource=acct:alice@your-prism-domain&rel=http://openid.net/specs/connect/1.0/issuer

returns a JRD (application/jrd+json) linking to the issuer:

json
{
  "subject": "acct:alice@your-prism-domain",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://your-prism-domain"
    }
  ]
}

Registering an application

  1. Log in to Prism and go to Apps → New Application
  2. Fill in the name, description, and redirect URIs
  3. 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:

TypeBehaviour
EqualsExact match after URL normalization (default; the safest option).
WildcardA glob where * stands in for any run of characters, e.g. https://example.com/*.
RegexA 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

text
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=S256

PKCE — generate a code_verifier (43–128 random URL-safe characters), then:

text
code_challenge = BASE64URL(SHA-256(ASCII(code_verifier)))

Scopes

ScopeClaims / access granted
openidsub, iss, aud, iat, exp (required for OIDC)
profilename, preferred_username, picture
profile:writeUpdate the user's profile (name, picture)
emailemail, email_verified
apps:readList of apps the user owns
apps:writeCreate, update, and delete the user's apps
teams:readList the user's teams
teams:writeUpdate team settings and manage members
teams:createCreate new teams
teams:deleteDelete teams
domains:readList the user's custom domains
domains:writeAdd and remove custom domains
gpg:readList the user's registered GPG public keys
gpg:writeAdd and remove GPG public keys
social:readList the user's linked social provider accounts
social:writeDisconnect social provider accounts
admin:users:readRead all user accounts (admin only)
admin:users:writeModify user accounts (admin only)
admin:users:deleteDelete user accounts (admin only)
admin:config:readRead instance configuration (admin only)
admin:config:writeUpdate instance configuration (admin only)
admin:invites:readList invitations (admin only)
admin:invites:createCreate invitations (admin only)
admin:invites:deleteDelete invitations (admin only)
offline_accessEnables refresh token issuance

Team scopes — three tiers

Three scope families touch teams, with very different blast radius. Pick the narrowest one that fits.

TierExampleScope of accessGranted by
Aggregate (plural)teams:readEvery team the user is a member ofNormal user consent
Single-team (singular)team:readExactly one team, picked at consent timeNormal user consent + team-id picker (admin+ on that team)
Cross-instancesite:team:readEvery team on the instanceAdmin-only, requires 2FA + confirmation phrase
Aggregate teams:*
teams:read   teams:write   teams:create   teams:delete

Acts 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:read

These 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, or admin of 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:delete additionally requires owner or co-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 grant team:delete on a sub-team and the recursive dissolveTeam cascade 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:delete

Cross-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. Use team:* if your integration scopes to a single team (e.g. a deploy bot for one workspace).
  • Don't request teams:* and team:* 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:*:

ScopeGrants
site:user:readRead every user account
site:user:writeModify any user
site:user:deleteDelete any user
site:team:readRead every team
site:team:writeModify any team
site:team:deleteDisband any team
site:config:readRead site config
site:config:writeModify site config
site:token:revokeRevoke 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:

text
https://yourapp.com/callback?code=<AUTH_CODE>&state=<STATE>

Always verify that state matches what you sent.

Step 4 — Exchange for tokens

http
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

json
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "id_token": "...",
  "scope": "openid profile email"
}

Step 5 — Call UserInfo

http
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

json
{
  "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

http
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:

http
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)

json
{
  "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)

http
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.

http
POST /api/oauth/device_authorization
Content-Type: application/x-www-form-urlencoded

client_id=<CLIENT_ID>
&scope=openid profile

Response:

json
{
  "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:

http
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:

http
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:

text
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:

http
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):

http
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:

http
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.com

The 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 gets login_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
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.

http
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):

json
{ "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:

text
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)

text
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:

http
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):

ClaimValue
issYour Prism instance URL
subStable user ID
audYour client_id
iatIssued-at timestamp
expExpiry timestamp
roleUser role (user or admin)
nonceEchoed 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:

ScopeField nameClaim(s) added to ID token
profile(always)name, preferred_username, picture
email(always)email, email_verified
teams:readteamsteams — array of { id, name, role, groups } objects for the user's team memberships
apps:readappsapps — array of { id, name, client_id, is_verified } objects for the user's apps
domains:readdomainsdomains — array of { id, domain, verified } objects
gpg:readgpg_keysgpg_keys — array of { id, fingerprint, key_id, name } objects
social:readsocial_accountssocial_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:

json
{ "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:

ClaimValueEmitted when
in_team_<team-id>trueThe user is a member of that team
role_in_team_<team-id>e.g. adminAlways, alongside in_team_<team-id>
groups_in_team_<team-id>array of slugsThe 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)

http
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"
}
FieldRequiredDescription
client_idyes (in Basic or body)Your OAuth app's client ID
client_secretconfidential clientsIn Basic auth or body
redirect_uriyesMust be registered on the OAuth app
actionrecommendedHuman-readable description (≤ 200 chars) of what the user is confirming. Shown verbatim on the Prism page and echoed in the verify response
nonceoptionalApp-defined opaque value (≤ 256 chars), echoed back. Bind it to the operation (e.g. an order ID)
code_challenge, code_challenge_methodrequired for public clientsPKCE — see Authorization Code flow

Response

json
{
  "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

text
https://prism.example.com/oauth/2fa?challenge_id=f3a…&state=RANDOM

The 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:

text
https://app.example.com/2fa-callback?code=…&state=…

Or, on denial / error:

text
https://app.example.com/2fa-callback?error=access_denied&state=…

Step 5 — Verify (server-side)

http
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_VERIFIER

Confidential clients send client_secret in the body or via HTTP Basic. Public clients use PKCE only.

Response

json
{
  "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_at is the unix timestamp the user completed 2FA — treat anything older than your acceptable window as stale.
  • Compare nonce and action against what your app stored when it built the URL — if they don't match, reject the result.
  • method is "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_2fa to demand a captcha for every step-up site-wide.
  • Per-app opt-in — apps include require_captcha: true when they call POST /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:

http
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, and state are 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_secret who 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:

text
?error=access_denied&error_description=User+denied+access

Token endpoint errors return HTTP 400:

json
{ "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

  1. Log in to Prism and go to Apps → New Application

  2. Set the redirect URI to:

    text
    https://<your-team-name>.cloudflareaccess.com/cdn-cgi/access/callback
  3. Set Allowed scopes to include at minimum openid and email. Add profile, teams:read, etc. if you need those claims in Access policies.

  4. 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.

  5. 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:

FieldValue
NamePrism (or any label)
App IDYour Prism Client ID
Client secretYour Prism Client Secret
Auth URLhttps://your-prism-domain/api/oauth/authorize
Token URLhttps://your-prism-domain/api/oauth/token
Certificate URLhttps://your-prism-domain/.well-known/jwks.json
PKCEEnabled (recommended)
Scopesopenid email (add profile teams:read etc. as needed)
OIDC ClaimsOne per line — the claim names you want usable in policies

Under OIDC Claims, enter the names of the custom claims Prism returns, for example:

text
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:

json
{
  "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:

SelectorClaim nameClaim valueEffect
OIDC ClaimroleadminPrism admins only
OIDC Claimin_team_<team-id>trueMembers of a specific team
OIDC Claimrole_in_team_<team-id>ownerTeam owners only
OIDC Claimgroups_in_team_<team-id>oncallMembers 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_fields setting.

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 of oncall matches any user whose array contains oncall. 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.

Released under the GPL-3.0 License.