Skip to content

Authentication flow

Prysm:ID speaks standard OIDC (the OpenID Connect profile of OAuth 2.0). If your favorite library speaks OIDC, it speaks to us with no proprietary glue.

The standard flow (Authorization Code + PKCE)

Section titled “The standard flow (Authorization Code + PKCE)”

The recommended flow for web apps, mobile, and SPAs. For pure backend-to-backend you use client credentials (see end).

┌──────────┐ ┌────────────────────┐
│ Your app │ │ auth.acme. │
│ (browser │ │ prysmid.com │
│ or BE) │ │ (instance) │
└────┬─────┘ └─────────┬──────────┘
│ │
│ 1. user clicks "Login" │
│ → redirect to /authorize │
│ with client_id, redirect_uri, │
│ scope=openid profile email, │
│ state, code_challenge (PKCE) │
├──────────────────────────────────────────────────▶│
│ │
│ 2. login UI: user enters email/pwd │
│ or picks a social IdP (Google/GitHub) │
│ │
│ 3. instance authenticates │
│ → redirect to redirect_uri │
│ with code + state │
│◀──────────────────────────────────────────────────┤
│ │
│ 4. POST /token │
│ code, code_verifier, client_id+secret │
├──────────────────────────────────────────────────▶│
│ │
│ 5. returns id_token (JWT) │
│ + access_token + refresh_token │
│◀──────────────────────────────────────────────────┤
│ │
│ 6. your BE validates id_token via JWKS │
│ (verifies signature, aud, exp, iss) │
│ │

The id_token is a signed JWT (RS256 by default). Decoded, the typical payload:

{
"iss": "https://auth.acme.prysmid.com",
"sub": "294857293874598734",
"aud": "275893745987345987@my-saas-dev",
"exp": 1738000000,
"iat": 1737996400,
"auth_time": 1737996398,
"email": "[email protected]",
"email_verified": true,
"name": "Fernando",
"preferred_username": "fernando"
}
  • sub: stable user identifier in your workspace. Persist it. Don’t use email as the key — emails change.
  • iss: the issuer. Must be exactly https://auth.<slug>.prysmid.com. If you validate against another string, you’ll accept forged tokens.
  • aud: your client_id. If you receive a token with a different aud, reject it.

How to validate the JWT in your backend →

ScopeWhat you get
openidRequired. Without it, no id_token.
profilename, preferred_username, picture.
emailemail, email_verified.
offline_accessReturns a refresh_token for long-lived sessions.
urn:zitadel:iam:org:project:rolesUser roles in the project tied to your app. (Canonical claim URN; don’t rewrite it in your validator.)

If you requested offline_access, you receive a refresh_token next to the id_token. Exchange it for a new id_token when the current one expires:

Ventana de terminal
curl -X POST https://auth.acme.prysmid.com/oauth/v2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=refresh_token" \
-d "refresh_token=$REFRESH_TOKEN"

The refresh token rotates on each use. Save the new one, discard the old.

If your app calls another app with no human in the loop (machine-to-machine), use client_credentials:

Ventana de terminal
curl -X POST https://auth.acme.prysmid.com/oauth/v2/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "scope=openid"

You won’t get an id_token (no user), just access_token. See also machine keys for agents if what you want is for an AI agent to operate the control plane.

GET https://auth.acme.prysmid.com/oidc/v1/end_session
?id_token_hint=<id_token>
&post_logout_redirect_uri=https://yourapp.com/logout

The post_logout_redirect_uri must be registered for your app. The instance ends the session on its side, then bounces back to your URI.