Deep dive
Decision table
| Situation | Flow / feature | Why |
|---|---|---|
| Server-side web app | Code + PKCE, confidential client | Secret stays on server; PKCE also stops code injection |
| SPA | BFF holding tokens, or code + PKCE public client with short tokens | Tokens in JS are XSS-exposed |
| Native/mobile app | Code + PKCE via system browser (ASWebAuthenticationSession, Custom Tabs), RFC 8252 | Embedded webviews can steal passwords |
| CLI, TV, IoT | Device authorization grant (RFC 8628) | No browser on the device |
| Service to service | Client credentials + private_key_jwt or mTLS | No user; no shared secrets |
| Service acting for a user downstream | Token exchange (RFC 8693) | Narrower, audience-bound tokens |
| High-value APIs (banking) | PAR (RFC 9126) + DPoP (RFC 9449) or mTLS-bound tokens | Stolen tokens useless without the key |
| Just "who is this user" | OIDC ID token → create your own session | Don't use access tokens as login proof |
How it works
Parts:
- client — your app; holds
state,nonce, PKCE verifier - authorization server (IdP) — authenticates the user, issues code, then tokens
- resource server (API) — validates the access token (audience, scope, expiry)
- JWKS endpoint — the IdP's public keys for ID-token and JWT checks
- Client creates
state,nonce, verifier and its S256 challenge, redirects to/authorize. - User authenticates and consents at the IdP; IdP redirects back with a one-time
codeandstate. - Client checks
state, POSTs code + verifier (+ client auth) to/token. - Client validates the ID token (signature,
iss,aud,exp,nonce) and creates its own session. - Client calls the API with the access token; refresh tokens renew it.
Browser Client (your app) Auth server (IdP) API
|-- /login ---->| make state, nonce, verifier; challenge = B64URL(SHA256(verifier))
|<-- 302 /authorize?response_type=code&client_id&redirect_uri&scope=openid
| &state&nonce&code_challenge&code_challenge_method=S256 ----->|
|------------------- user authenticates, consents --------------->|
|<-- 302 redirect_uri?code=...&state=... --------------------------|
|-- callback --->| check state; POST /token code+verifier(+client auth)
| |<-- access_token, id_token, refresh_token ------|
| | validate id_token, create session
| |-- Authorization: Bearer access_token ---------------------->|
Safe defaults
- Access tokens: 5-60 min, audience-restricted (
aud= one API), minimal scopes. APIs reject tokens minted for other audiences. - Refresh tokens: rotate on use for public clients; bind to the client (DPoP) where supported.
- Authorization code: single use, expires in order of a minute; PKCE verifier 43-128 chars of high entropy.
- JWKS: cache by
kid, refetch on unknownkidwith rate limiting, never fetch keys from a URL inside the token (jku,x5u). - After OIDC login, create your own short session; logout must clear it and, if needed, call the IdP's end-session endpoint.
Code
Rust: PKCE, request building and ID-token checks (rand, sha2, base64 = "0.22", url = "2", jsonwebtoken = "9"). Fetch and cache the IdP's JWKS from its discovery document (/.well-known/openid-configuration).
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use rand::{rngs::OsRng, RngCore};
use serde::Deserialize;
use sha2::{Digest, Sha256};
fn random_b64(n: usize) -> String {
let mut b = vec![0u8; n];
OsRng.fill_bytes(&mut b);
B64.encode(b)
}
/// Saved in the user's pre-login server session, keyed by `state`.
pub struct Pending { pub state: String, pub nonce: String, pub verifier: String }
pub fn authorize_url(issuer_auth_endpoint: &str, client_id: &str, redirect: &str) -> (url::Url, Pending) {
let p = Pending { state: random_b64(32), nonce: random_b64(32), verifier: random_b64(32) };
let challenge = B64.encode(Sha256::digest(p.verifier.as_bytes()));
let mut u = url::Url::parse(issuer_auth_endpoint).unwrap();
u.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", client_id)
.append_pair("redirect_uri", redirect) // must match a registered URI exactly
.append_pair("scope", "openid email")
.append_pair("state", &p.state)
.append_pair("nonce", &p.nonce)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256");
(u, p)
}
// Token request (form POST to token_endpoint): grant_type=authorization_code, code,
// redirect_uri, code_verifier=p.verifier, plus client authentication.
#[derive(Deserialize)]
pub struct IdClaims { pub sub: String, pub nonce: String, pub email: Option<String>, pub email_verified: Option<bool> }
pub fn validate_id_token(
id_token: &str, issuer: &str, client_id: &str, expected_nonce: &str,
jwk_for_kid: impl Fn(&str) -> Option<DecodingKey>,
) -> Option<IdClaims> {
let kid = decode_header(id_token).ok()?.kid?;
let key = jwk_for_kid(&kid)?; // from cached JWKS; refetch on unknown kid
let mut v = Validation::new(Algorithm::RS256); // the alg your IdP documents, fixed
v.set_issuer(&[issuer]);
v.set_audience(&[client_id]);
let c = decode::<IdClaims>(id_token, &key, &v).ok()?.claims;
(c.nonce == expected_nonce).then_some(c)
}
Identify users by (iss, sub), never by email. Prefer a maintained library (openidconnect in Rust, Authlib in Python, openid-client in Node) that does all of the above.
Efficiency
- Cost: a local JWT signature check is order of tens of µs (Ed25519); each IdP call is a network round trip, order of milliseconds. Bound: network latency to the IdP.
- Levers:
- Validate access tokens locally — one local check instead of one introspection call per request — revocation lag up to the token lifetime is acceptable.
- Cache JWKS by
kidand the discovery document — no per-request fetch; refetch on unknownkid, rate-limited — always. - Own session after login — ID token verified once, then a cookie — web apps.
- Refresh tokens — silent renewal instead of a new redirect — long sessions.
- Naive → standard → state of the art:
- Naive · introspect every token, fetch JWKS per request, redo the flow often
- Standard · local JWT validation with cached keys, own session cookie, refresh tokens
- State of the art · same, plus sender-constrained tokens (DPoP) for high-value APIs; plain sessions when no IdP is needed
In production
- Finance: open-banking ecosystems in many countries use the OpenID Foundation's FAPI profile of OAuth 2 for data sharing (OpenID FAPI).
- Healthcare: SMART on FHIR defines OAuth 2.0 patterns for apps to authorize against FHIR health-record systems (HL7 SMART App Launch).
- Government: Login.gov, the US government's sign-in service, offers an OpenID Connect integration to agencies (Login.gov).
- Cloud infra: the Kubernetes API server authenticates users with OIDC ID tokens (Kubernetes docs).
Pitfalls
- Skipping
statecheck: login CSRF (attacker logs victim into attacker's account). - Prefix or wildcard redirect URI matching: open redirect → stolen codes.
- Linking accounts by unverified email from the IdP: account takeover. Require
email_verifiedand prefersub. - Accepting ID tokens with another client's
aud(token from a different app at the same IdP). - Treating an access token as proof of login, or sending ID tokens to APIs.
- Long-lived refresh tokens in browsers without rotation.
- Logging full callback URLs (contain codes) or tokens.
- Client secret embedded in a mobile app or SPA: it's public; use PKCE as a public client.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| openidconnect | Rust | ramosbugs/openidconnect-rs | cargo add openidconnect | — | — | — | — | OIDC client: discovery, code + PKCE, ID-token verification |
| oauth2 | Rust | ramosbugs/oauth2-rs | cargo add oauth2 | — | — | — | — | typed OAuth 2 (RFC 6749) client; basis of openidconnect |
| Keycloak | Java | keycloak/keycloak | n/a (service) | — | — | — | — | self-hosted IdP: OIDC, SAML, user federation |
| ZITADEL | Go | zitadel/zitadel | n/a (service) | — | — | — | — | identity platform with OIDC, self-hosted or cloud |
| Ory Hydra | Go | ory/hydra | n/a (service) | — | — | — | — | OpenID Certified OAuth 2 / OIDC server; you supply the login and consent UI |
Sources
- Hardt, RFC 6749: The OAuth 2.0 Authorization Framework, 2012. RFC
- Sakimura, Bradley, Agarwal, RFC 7636: Proof Key for Code Exchange (PKCE), 2015. RFC
- Lodderstedt, Bradley, Labunets, Fett, RFC 9700: Best Current Practice for OAuth 2.0 Security, 2025. RFC
- Sakimura et al., OpenID Connect Core 1.0, OpenID Foundation. spec
- Denniss, Bradley, RFC 8252: OAuth 2.0 for Native Apps, 2017. RFC
- Lodderstedt et al., RFC 9126: OAuth 2.0 Pushed Authorization Requests, 2021. RFC
- Fett et al., RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP), 2023. RFC