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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- oauth2-oidc Installs to ~/.agents/skills/oauth2-oidc/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install oauth2-oidc The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project oauth2-oidc Installs to .agents/skills/oauth2-oidc/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/oauth2-oidc.zipunzip -o oauth2-oidc.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) oauth2-oidc PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- You want a framework that bundles providers and sessions: use auth-libraries
- You are auditing an OAuth deployment for vulnerabilities: use oauth-security-review
- You need delegated access to a third-party API such as Google Drive: use oauth-integrations
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.
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
| Client | Flow | Notes |
|---|---|---|
| Server-rendered web app | Authorization code + PKCE, confidential client | Tokens stay on the server; app issues its own session (session-auth) |
| SPA | Backend-for-frontend (BFF) doing code + PKCE | Browser holds only a session cookie. Pure in-browser tokens only if a BFF is impossible |
| Native / mobile app | Code + PKCE via system browser, claimed HTTPS or custom-scheme redirect | Never an embedded webview |
| CLI or TV | Device authorization grant (RFC 8628) | Or loopback redirect http://127.0.0.1:<port> with PKCE |
| Service to service | Client credentials | Secret in a vault, or private_key_jwt / mTLS |
| Your API receiving tokens | Resource server validation | JWT 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
- Read
AGENTS.md, then find existing auth code:rg -n "oauth|oidc|openid|redirect_uri|callback|id_token". - 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}' - Record per environment: issuer URL (exact string, trailing slash matters), client ID, registered redirect URIs, scopes, and whether the client is confidential.
- Never commit client secrets. Load them from env or a secret manager.
Authorization code + PKCE, step by step
- Generate
code_verifier(43-128 chars, high entropy),code_challenge = BASE64URL(SHA256(verifier)), randomstateandnonce. Store all three server-side (session) or in a short-lived, encrypted,HttpOnlycookie bound to this browser. - Redirect to
authorization_endpointwithresponse_type=code,client_id, exactredirect_uri,scope=openid profile email,state,nonce,code_challenge,code_challenge_method=S256. - On callback: reject if
erroris present (show a friendly page, logerror_description), ifstatedoes not match, or if the stored transaction is missing or older than ~10 min. - Exchange the code at
token_endpointwithcode_verifierand client authentication. - Validate the ID token (below). Then delete the stored transaction (one use only).
- 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 bykid, algorithm from an allowlist (nevernone, never HS* with a public key). issequals the discovered issuer exactly.audcontains your client ID; if multiple audiences,azpequals your client ID.expin the future,iatrecent (allow 30-60 s skew).nonceequals 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) androles(app), Auth0 and Oktascopeorpermissions. Check a real token withjwt-auth’sjwt_audit.pybefore 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
| Provider | Watch for |
|---|---|
| Entra ID | Issuer contains tenant ID; multi-tenant apps (common) must validate tid against an allowlist themselves |
hd claim is the Workspace domain; check it server-side, the hd request param is only a hint | |
| Auth0 | Without an audience param you get an opaque token for /userinfo, not a JWT for your API |
| Keycloak | Issuer includes /realms/<name>; frontend and backend URLs must produce the same issuer string |
| Okta | Org 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
statein 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_uriin 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_verifiedis 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
httpor the internal host, so the token exchange fails withredirect_uri mismatch. SameSite=Stricton the transaction cookie drops it on the cross-site redirect back; useLax.- 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), useoauth-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.yaml
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.