OAuth 2.1 and OIDC skill for Codex

SSO logins that validate every hop. Codex picks the right grant for each client (code with PKCE, BFF, device flow, client credentials), then implements it with openid-client, Authlib or Spring Security: state, nonce and one-time transactions, full ID token validation, users keyed by issuer and subject rather than email, and APIs that reject tokens minted for another audience. Provider quirks for Entra, Google, Auth0, Okta and Keycloak are covered.

  • Backend & APIs
  • $oauth2-oidc
  • 169 lines
  • 2 files
  • ~2,200 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- oauth2-oidc

Installs to ~/.agents/skills/oauth2-oidc/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $oauth2-oidc, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Implements OAuth 2.1 and OpenID Connect correctly - authorization code with PKCE (S256), state and nonce, exact redirect URIs, ID token validation, discovery and JWKS, client credentials for services, device flow for CLIs, and resource-server token validation - with openid-client, Authlib and Spring Security against Entra ID, Google, Okta, Auth0 or Keycloak. Use when adding SSO or "sign in with" a provider, protecting an API with provider-issued tokens, building a BFF for an SPA, or fixing a login callback bug.

Use it when

  • Adding SSO or social login with an OpenID Connect provider
  • An API must validate access tokens issued by an identity provider
  • An SPA needs login without keeping tokens in the browser
  • A CLI or service needs device flow or client credentials

Use something else when

How to check the result

Tampered state, a replayed callback, a modified ID token signature and an access token for another audience must all be rejected, and users are found by issuer plus subject.

The mistake it prevents

Linking accounts by email address from the ID token, which lets an attacker with an unverified matching email at some provider take over a user.

~/.agents/skills/oauth2-oidc/SKILL.md

--- name: oauth2-oidc description: Implements OAuth 2.1 and OpenID Connect correctly - authorization code with PKCE (S256), state and nonce, exact redirect URIs, ID token validation,… license: MIT ---

Done well means: every client uses authorization code with PKCE, every callback checks state and nonce, ID tokens are validated before you trust a single claim, users are keyed by iss + sub, and APIs accept only access tokens minted for their audience.

Pick the flow

ClientFlowNotes
Server-rendered web appAuthorization code + PKCE, confidential clientTokens stay on the server; app issues its own session (session-auth)
SPABackend-for-frontend (BFF) doing code + PKCEBrowser holds only a session cookie. Pure in-browser tokens only if a BFF is impossible
Native / mobile appCode + PKCE via system browser, claimed HTTPS or custom-scheme redirectNever an embedded webview
CLI or TVDevice authorization grant (RFC 8628)Or loopback redirect http://127.0.0.1:<port> with PKCE
Service to serviceClient credentialsSecret in a vault, or private_key_jwt / mTLS
Your API receiving tokensResource server validationJWT via JWKS, or introspection (RFC 7662) for opaque tokens

Removed in OAuth 2.1 and never to be added: implicit grant (response_type=token), resource owner password grant, PKCE plain method, redirect URI wildcards or prefix matching.

Before changing anything

  1. Read AGENTS.md, then find existing auth code: rg -n "oauth|oidc|openid|redirect_uri|callback|id_token".
  2. Get the provider’s discovery document (needs network): curl -s https://<issuer>/.well-known/openid-configuration | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri, end_session_endpoint, code_challenge_methods_supported}'
  3. Record per environment: issuer URL (exact string, trailing slash matters), client ID, registered redirect URIs, scopes, and whether the client is confidential.
  4. Never commit client secrets. Load them from env or a secret manager.

Authorization code + PKCE, step by step

  1. Generate code_verifier (43-128 chars, high entropy), code_challenge = BASE64URL(SHA256(verifier)), random state and nonce. Store all three server-side (session) or in a short-lived, encrypted, HttpOnly cookie bound to this browser.
  2. Redirect to authorization_endpoint with response_type=code, client_id, exact redirect_uri, scope=openid profile email, state, nonce, code_challenge, code_challenge_method=S256.
  3. On callback: reject if error is present (show a friendly page, log error_description), if state does not match, or if the stored transaction is missing or older than ~10 min.
  4. Exchange the code at token_endpoint with code_verifier and client authentication.
  5. Validate the ID token (below). Then delete the stored transaction (one use only).
  6. Look up or create the local user by (iss, sub). Rotate your session ID and log the user in.

Node (openid-client v6)

import * as client from "openid-client";

const config = await client.discovery(
  new URL(process.env.OIDC_ISSUER!), process.env.OIDC_CLIENT_ID!, process.env.OIDC_CLIENT_SECRET!,
);

export async function startLogin(req, res) {
  const code_verifier = client.randomPKCECodeVerifier();
  const code_challenge = await client.calculatePKCECodeChallenge(code_verifier);
  const state = client.randomState();
  const nonce = client.randomNonce();
  req.session.oidc = { code_verifier, state, nonce, startedAt: Date.now() };
  const url = client.buildAuthorizationUrl(config, {
    redirect_uri: "https://app.example.com/auth/callback",
    scope: "openid profile email",
    code_challenge, code_challenge_method: "S256", state, nonce,
  });
  res.redirect(url.href);
}

export async function callback(req, res) {
  const tx = req.session.oidc;
  delete req.session.oidc;
  if (!tx || Date.now() - tx.startedAt > 10 * 60_000) return res.status(400).send("Login expired");
  const currentUrl = new URL(req.originalUrl, "https://app.example.com");
  const tokens = await client.authorizationCodeGrant(config, currentUrl, {
    pkceCodeVerifier: tx.code_verifier, expectedState: tx.state, expectedNonce: tx.nonce,
  });                                       // validates state, ID token signature, iss, aud, exp, nonce
  const claims = tokens.claims()!;
  const user = await upsertUser({ issuer: claims.iss, subject: claims.sub, email: claims.email,
                                  emailVerified: claims.email_verified === true });
  await loginWithRotatedSession(req, user);
  res.redirect("/");
}

Python: Authlib’s framework integrations (authlib.integrations.starlette_client, flask_client, django_client) handle PKCE, state and nonce when registered with server_metadata_url and code_challenge_method="S256". Java: Spring Security oauth2Login() with spring.security.oauth2.client.registration.* properties.

ID token validation (if the library does not)

Reject unless all hold:

  • Signature verifies with a key from the issuer’s jwks_uri, selected by kid, algorithm from an allowlist (never none, never HS* with a public key).
  • iss equals the discovered issuer exactly.
  • aud contains your client ID; if multiple audiences, azp equals your client ID.
  • exp in the future, iat recent (allow 30-60 s skew).
  • nonce equals the value you stored for this login.

Use the ID token to learn who logged in, then discard it. It is not an API credential.

Protecting an API (resource server)

# Validate provider access tokens with a pinned audience and issuer
claims = jwt.decode(token, signing_key, algorithms=["RS256"],
                    audience="api://orders", issuer=ISSUER, leeway=30)
if "orders.write" not in claims.get("scp", "").split():   # claim name varies: scp, scope, roles
    raise Forbidden()
  • Require the API’s own audience. A token minted for another app at the same provider must fail.
  • Scope claim names differ: Entra uses scp (delegated) and roles (app), Auth0 and Okta scope or permissions. Check a real token with jwt-auth’s jwt_audit.py before writing checks.
  • Opaque access tokens (some Google, Okta and Keycloak configs) require introspection; cache results for less than the token lifetime.

Logout

Clear your own session first. Then, if the user should leave the IdP too, redirect to end_session_endpoint with id_token_hint and a registered post_logout_redirect_uri. Back-channel logout (provider calls you) needs a session index keyed by sid.

Provider notes

ProviderWatch for
Entra IDIssuer contains tenant ID; multi-tenant apps (common) must validate tid against an allowlist themselves
Googlehd claim is the Workspace domain; check it server-side, the hd request param is only a hint
Auth0Without an audience param you get an opaque token for /userinfo, not a JWT for your API
KeycloakIssuer includes /realms/<name>; frontend and backend URLs must produce the same issuer string
OktaOrg authorization server vs custom authorization server have different issuers and token audiences

Hardening options

  • PAR (RFC 9126) to keep authorization parameters off the front channel.
  • DPoP (RFC 9449) or mTLS to sender-constrain tokens for public clients.
  • Refresh token rotation for public clients; offline access only when needed.

Verification

  • Tamper state in the callback URL: expect rejection. Replay a used callback URL: expect rejection.
  • Modify one character of the ID token signature: expect rejection.
  • Call the API with a token for a different audience: expect 401.
  • Register a second, unregistered redirect_uri in the request: the provider must refuse.
  • Account linking: logging in with a provider whose email matches an existing local user must not merge accounts unless email_verified is true and the user confirms.

Gotchas

  • Matching users by email instead of (iss, sub) lets anyone who controls an unverified email at any provider take over an account.
  • Behind a proxy, the callback URL the library reconstructs uses http or the internal host, so the token exchange fails with redirect_uri mismatch.
  • SameSite=Strict on the transaction cookie drops it on the cross-site redirect back; use Lax.
  • Caching JWKS forever breaks on provider key rotation; refetch on unknown kid.
  • Storing refresh tokens from providers in plaintext; encrypt them at rest.
  • For reviewing someone else’s OAuth implementation for vulnerabilities, use oauth-security-review. For connecting to third-party APIs on a user’s behalf (Google Drive, Slack), use oauth-integrations.

Output

Report the flow per client, issuer and redirect URIs per environment, which validations run where, the user-keying rule, logout behaviour, and tests added.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "OAuth 2.1 and OIDC"
  short_description: "SSO logins that validate every hop"
  default_prompt: "$oauth2-oidc Add Sign in with Microsoft Entra ID to this Express app using PKCE"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem