Home › Security › Authentication & authorization
guide low

OAuth 2 and OpenID Connect

Problem Teams adding "Sign in with Google/Microsoft/Okta", enterprise SSO, or letting third parties call their API for users must choose a safe login and delegated-access flow. This guide answers which flow to use and what goes wrong.

Delegated authorization (OAuth 2) and login on top of it (OIDC); use authorization code + PKCE for every user-facing client.

oauth2oidcssopkceauthorization-codejwt
Overview

OAuth 2 gives a client a scoped access token for an API without seeing the user's password; OpenID Connect adds an ID token saying who logged in. Safe deployments use one flow: redirect to the authorization server, get a one-time code, exchange it plus a PKCE verifier for tokens. Breaches come from skipped checks, not broken crypto.

Key facts

Use when

  • "Sign in with Google/Microsoft/Okta", enterprise SSO, or letting third parties call your API on users' behalf.
  • Open banking, patient-data apps, citizen sign-in to government services, cluster access in cloud platforms (see In production).

Avoid when

  • First-party app, own user DB, no SSO: plain sessions are simpler.
  • Writing your own authorization server: use Keycloak, Zitadel, Auth0, Cognito or Entra ID.

Deep dive

Decision table

SituationFlow / featureWhy
Server-side web appCode + PKCE, confidential clientSecret stays on server; PKCE also stops code injection
SPABFF holding tokens, or code + PKCE public client with short tokensTokens in JS are XSS-exposed
Native/mobile appCode + PKCE via system browser (ASWebAuthenticationSession, Custom Tabs), RFC 8252Embedded webviews can steal passwords
CLI, TV, IoTDevice authorization grant (RFC 8628)No browser on the device
Service to serviceClient credentials + private_key_jwt or mTLSNo user; no shared secrets
Service acting for a user downstreamToken exchange (RFC 8693)Narrower, audience-bound tokens
High-value APIs (banking)PAR (RFC 9126) + DPoP (RFC 9449) or mTLS-bound tokensStolen tokens useless without the key
Just "who is this user"OIDC ID token → create your own sessionDon'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
  1. Client creates state, nonce, verifier and its S256 challenge, redirects to /authorize.
  2. User authenticates and consents at the IdP; IdP redirects back with a one-time code and state.
  3. Client checks state, POSTs code + verifier (+ client auth) to /token.
  4. Client validates the ID token (signature, iss, aud, exp, nonce) and creates its own session.
  5. 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 unknown kid with 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 kid and the discovery document — no per-request fetch; refetch on unknown kid, 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 state check: 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_verified and prefer sub.
  • 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

namelangrepoinstallverdictlicencehealthas_ofnotes
openidconnectRustramosbugs/openidconnect-rscargo add openidconnect————OIDC client: discovery, code + PKCE, ID-token verification
oauth2Rustramosbugs/oauth2-rscargo add oauth2————typed OAuth 2 (RFC 6749) client; basis of openidconnect
KeycloakJavakeycloak/keycloakn/a (service)————self-hosted IdP: OIDC, SAML, user federation
ZITADELGozitadel/zitadeln/a (service)————identity platform with OIDC, self-hosted or cloud
Ory HydraGoory/hydran/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

Builds on

Used by

Systems (62):

Alternatives and related