Security No. 280

OAuth 2.1 and OIDC skill for Claude Code

PKCE, state and nonce, checked every single time. Keeps Claude on the authorization code + PKCE path for every client type, with state, nonce and issuer checks tied to the browser session. It validates ID tokens by signature, iss, aud and nonce, identifies users by issuer plus subject rather than email, keeps tokens out of the browser with a BFF, and audits existing setups against the misconfigurations behind real account takeovers.

  • nameoauth-oidc
  • size168 lines · ≈2,100 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Implements and reviews OAuth 2.1 and OpenID Connect integrations — authorization code flow with PKCE, state and nonce, exact redirect URI matching, ID token and access token validation, scopes, refresh token rotation, BFF pattern for SPAs, and the misconfigurations behind account takeover. Use when adding "Sign in with Google/Microsoft/Okta", building an OAuth client or resource server, choosing a flow for SPA/mobile/backend, debugging invalid_grant or redirect_uri errors, or auditing an OAuth setup.

Try asking

  • Add Sign in with Google to our Express app using OIDC and PKCE
  • Our token exchange keeps returning invalid_grant — figure out why
  • Review how our SPA handles OAuth tokens and tell me what is unsafe

Use it when

  • Adding social login or enterprise SSO through an OIDC provider
  • Building an API that accepts OAuth access tokens
  • Choosing a flow for SPA, mobile, CLI, or machine-to-machine clients
  • Debugging redirect_uri_mismatch, invalid_grant, or state errors

Skip it when

  • The work is JWT signing and verification mechanics alone — use jwt-handling instead
  • You are wiring a hosted auth SDK such as Clerk or Auth0 — use auth-providers instead

How to check the result

A tampered state, a replayed authorization code, and an ID token with the wrong audience are all rejected, and no tokens appear in localStorage or URLs.

The common mistake it prevents

Linking accounts by the email claim instead of issuer plus subject, letting anyone with an unverified matching email at some IdP take over the account.

~/.claude/skills/oauth-oidc/SKILL.md

--- name: oauth-oidc description: Implements and reviews OAuth 2.1 and OpenID Connect integrations — authorization code flow with PKCE, state and nonce, exact redirect URI… license: MIT ---

Done well means: every client uses authorization code + PKCE, redirect URIs match exactly, state and nonce are bound to the user’s browser session and checked, tokens are validated fully (signature, iss, aud, exp, nonce), access tokens never authenticate a user and ID tokens never authorize an API call, and tokens live where script cannot read them. Follow RFC 9700 (OAuth 2.0 Security Best Current Practice) where 2.1 is still draft.

Hard rules

  • Use a certified, maintained library for the protocol. Hand-rolling is for understanding, not production.
  • Implicit flow and Resource Owner Password Credentials are removed in OAuth 2.1. Do not add them; migrate existing uses.
  • PKCE with S256 for every client, confidential ones included.
  • Register exact redirect URIs. No wildcards, no prefix matching, no open redirects on the callback path.
  • OAuth is authorization. Login (“who is this user?”) requires OIDC and a validated ID token, or a userinfo call made with a token issued to your client.

Choose the flow

ClientFlowToken storage
Server-rendered web appAuth code + PKCE, confidential client (client secret or private_key_jwt)Server-side session; tokens never reach the browser
SPABFF: a backend-for-frontend does auth code + PKCE and issues an HttpOnly session cookieServer-side (preferred). Pure-browser token handling only if no backend is possible: memory storage, short TTL, refresh rotation
Native / mobileAuth code + PKCE via system browser (ASWebAuthenticationSession, Custom Tabs), claimed HTTPS or private-use redirectOS keychain / Keystore
Machine to machineClient credentials (secret, private_key_jwt, or mTLS)Process memory / secret store
TV / CLI without browserDevice authorization grant (RFC 8628)OS keychain

Embedded webviews for login are banned by most IdPs and enable credential phishing.

Authorization code + PKCE: what each step must do

  1. Generate per-attempt secrets and store them server-side (or in an encrypted, HttpOnly cookie) tied to this browser:
    • code_verifier: 43–128 chars from a CSPRNG.
    • code_challenge = BASE64URL(SHA256(code_verifier)), code_challenge_method=S256.
    • state: random, unguessable — CSRF protection for the callback.
    • nonce (OIDC): random — binds the ID token to this request.
  2. Redirect to the authorize endpoint with response_type=code, client_id, redirect_uri, scope (include openid for OIDC), state, nonce, code_challenge, code_challenge_method.
  3. On callback: if the response includes iss (RFC 9207), check it equals the expected issuer (defends against mix-up attacks with multiple IdPs). Compare state to the stored value; reject on mismatch or missing. Reject error responses explicitly.
  4. Exchange the code at the token endpoint with code_verifier and the same redirect_uri. Codes are single-use and short-lived (typically ≤ 60 s).
  5. Validate the ID token (below), then create your own session. Discard the stored verifier, state, and nonce.

Generating PKCE values (Python standard library):

import base64, hashlib, secrets
verifier = secrets.token_urlsafe(64)[:96]
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
state, nonce = secrets.token_urlsafe(32), secrets.token_urlsafe(32)

Node with openid-client v6:

import * as client from "openid-client";

const config = await client.discovery(new URL("https://accounts.example.com"), CLIENT_ID, CLIENT_SECRET);

// login route
const code_verifier = client.randomPKCECodeVerifier();
const code_challenge = await client.calculatePKCECodeChallenge(code_verifier);
const state = client.randomState();
const nonce = client.randomNonce();
req.session.oauth = { code_verifier, state, nonce };
const url = client.buildAuthorizationUrl(config, {
  redirect_uri: REDIRECT_URI, scope: "openid email profile",
  code_challenge, code_challenge_method: "S256", state, nonce,
});
res.redirect(url.href);

// callback route
const { code_verifier, state, nonce } = req.session.oauth;
const tokens = await client.authorizationCodeGrant(config, new URL(req.originalUrl, BASE_URL), {
  pkceCodeVerifier: code_verifier, expectedState: state, expectedNonce: nonce,
});
const claims = tokens.claims(); // validated ID token claims

Python: use Authlib (authlib.integrations.flask_client / starlette_client) or django-allauth; they handle state, nonce, and JWKS when configured with the IdP’s server_metadata_url.

ID token validation (OIDC Core §3.1.3.7)

  • Signature with a key from the issuer’s JWKS (jwks_uri from discovery), selected by kid; algorithm from an allowlist (e.g. RS256, ES256, PS256). Never accept none.
  • iss equals the discovered issuer exactly.
  • aud contains your client_id; if multiple audiences, azp equals your client_id.
  • exp in the future, iat reasonable (allow ≤ 60 s clock skew).
  • nonce equals the value you stored.
  • Identify users by iss + sub, never by email. Email can change and, for some IdPs and tenants, is not verified (email_verified false or absent).

Resource server (API) access token validation

  • JWT access tokens (RFC 9068): verify signature via JWKS, iss, aud equals this API’s identifier, exp, and required scope/scp or roles for the operation.
  • Opaque tokens: call the introspection endpoint (RFC 7662) and cache the result briefly.
  • Reject ID tokens presented as access tokens (typ header at+jwt helps; aud check catches most cases).
  • Check scopes per endpoint and still do object-level authorization; a read:invoices scope does not mean every invoice.

Refresh tokens

  • Public clients: refresh token rotation (new refresh token on every use; reuse of an old one revokes the family) or sender-constrained tokens (DPoP, RFC 9449).
  • Set absolute lifetimes; revoke on logout (revocation_endpoint, RFC 7009) and on password change at the IdP where supported.
  • Request offline_access only when you need refresh without the user present.
  • Request the minimum; add incremental scopes when the feature is used.
  • Map scopes to API permissions explicitly in one place.
  • For multi-tenant IdPs (Microsoft Entra common, Google Workspace), validate the tenant (tid, hd) when only certain organizations may sign in. hd is a hint; confirm via the ID token claim, not the request parameter.

Common misconfigurations (audit list)

- [ ] Missing or unchecked state -> login CSRF (attacker logs victim into attacker account)
- [ ] Redirect URI wildcard / prefix match / open redirect on callback -> code theft
- [ ] No PKCE or `plain` method -> code interception
- [ ] ID token signature/iss/aud/nonce not checked, or decoded without verification
- [ ] Users matched by email instead of iss+sub -> account takeover via unverified email
- [ ] Access token from another client accepted as login (token substitution)
- [ ] Tokens in localStorage or URLs; tokens logged
- [ ] Client secret shipped in SPA or mobile binary (it is public)
- [ ] Refresh tokens long-lived without rotation for public clients
- [ ] Multi-IdP setup without issuer check on callback (mix-up)
- [ ] Logout clears local session but not IdP session when single logout is required

Debugging common errors

ErrorUsual cause
redirect_uri_mismatchScheme, host, port, path, or trailing slash differs from registration
invalid_grant on token exchangeCode reused or expired, code_verifier wrong/missing, redirect_uri differs from authorize request, clock skew
invalid_clientWrong auth method (client_secret_basic vs client_secret_post), secret rotated
ID token aud mismatchValidating with the wrong client ID or an API audience
State mismatch after redirectSession cookie lost: SameSite=Strict on the session cookie blocks it on the IdP redirect; use Lax

Verification

  • Callback with a tampered or missing state is rejected.
  • Replaying a used authorization code fails.
  • An ID token with wrong aud, expired exp, or a modified payload is rejected (unit test with a locally signed token and a test JWKS).
  • Inspect the browser: no tokens in localStorage, URLs, or readable cookies for BFF/server apps.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc