JWT Auth skill for Codex
Short-lived access, rotating refresh, no surprises. Codex treats a JWT as a short-lived, unrevocable credential and designs around that: 10-minute ES256 access tokens verified with pinned algorithm, issuer and audience, opaque refresh tokens stored hashed and rotated with family revocation on reuse, and a kid-based JWKS rotation runbook. A bundled offline script decodes tokens and flags alg none, missing claims and millisecond expiries.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- jwt-auth Installs to ~/.agents/skills/jwt-auth/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install jwt-auth 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 jwt-auth Installs to .agents/skills/jwt-auth/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/jwt-auth.zipunzip -o jwt-auth.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))) jwt-auth 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 $jwt-auth, 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
Designs and hardens JWT bearer authentication - short-lived access tokens, opaque rotating refresh tokens with reuse detection, revocation strategies, pinned algorithms, iss/aud/exp validation, kid-based key rotation and JWKS publishing - with jose (Node), PyJWT and golang-jwt. Includes a script that decodes a token and flags risky claims. Use when issuing or verifying JWTs, adding refresh tokens, rotating signing keys, fixing alg or expiry findings, or deciding where a browser should store tokens.
Use it when
- An API issues or verifies JWT bearer tokens
- Refresh tokens, logout or revoke-all must be added to token auth
- Signing keys need rotation or a JWKS endpoint
- A review flagged alg, expiry or audience validation
Use something else when
- A same-site browser app could use cookies instead: use session-auth
- Tokens come from an external identity provider via a login flow: use oauth2-oidc
How to check the result
Negative tests reject alg none, wrong aud or iss, expired tokens and ID tokens, and replaying a rotated refresh token returns 401 while revoking its family.
The mistake it prevents
Issuing day-long access tokens with no revocation path, so a leaked token or a demoted user keeps full access until it expires.
Done well means: access tokens are short-lived and verified with a pinned algorithm, issuer, audience and expiry; refresh tokens are opaque, stored hashed, rotated on every use and revoked as a family on reuse; and signing keys can be rotated without logging anyone out. A JWT you cannot revoke must die quickly.
Decide first
| Situation | Choice |
|---|---|
| Browser app on the same site as its API | Prefer session-auth; JWTs add revocation problems for no gain |
| Mobile app, CLI, or several APIs verifying one login | Short JWT access token + opaque refresh token (this skill) |
| Users sign in through an external IdP (Auth0, Entra, Keycloak, Cognito) | You only verify their tokens; read oauth2-oidc for the flow |
| Service-to-service calls | OAuth client credentials or mTLS; JWT verified with aud per service |
Never accept a design where a long-lived JWT (days) is the only credential. That is an unrevocable password with an expiry date.
Token design
| Token | Format | Lifetime | Storage (browser) | Storage (server) |
|---|---|---|---|---|
| Access | JWT, asymmetric signature | 5-15 min | Memory only | Nothing (stateless) |
| Refresh | Opaque 256-bit random string | 7-30 d sliding, 90 d absolute cap | HttpOnly; Secure; SameSite=Strict cookie scoped to Path=/auth/refresh | sha256(token), user_id, family_id, expires_at, used_at, revoked_at |
Access token claims: iss, sub (stable user ID, never email), aud, exp, iat, jti,
plus minimal authorization data (scope, tenant_id). Use header typ: "at+jwt" (RFC 9068)
so ID tokens and other JWTs cannot be confused with access tokens. Never put PII, secrets
or anything you would not print in a log into the payload: it is base64, not encrypted.
Algorithm: ES256 or EdDSA by default. RS256 when an ecosystem requires it. HS256 only
when the issuer and the single verifier are the same service, because anyone who can verify
can also mint.
Issuing and verifying
Node (jose)
import { SignJWT, jwtVerify, createRemoteJWKSet, importPKCS8 } from "jose";
const privateKey = await importPKCS8(process.env.JWT_PRIVATE_KEY_PEM!, "ES256");
export async function issueAccessToken(user: { id: string; tenantId: string }) {
return new SignJWT({ scope: "orders:read orders:write", tenant_id: user.tenantId })
.setProtectedHeader({ alg: "ES256", kid: process.env.JWT_KID!, typ: "at+jwt" })
.setIssuer("https://auth.example.com")
.setAudience("https://api.example.com")
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime("10m")
.setJti(crypto.randomUUID())
.sign(privateKey);
}
const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
algorithms: ["ES256"], // pin: never derive the algorithm from the token
typ: "at+jwt",
clockTolerance: 30, // seconds
});
return payload;
}
Python (PyJWT)
import jwt
from jwt import PyJWKClient
jwks = PyJWKClient("https://auth.example.com/.well-known/jwks.json", cache_keys=True)
def verify(token: str) -> dict:
key = jwks.get_signing_key_from_jwt(token).key
return jwt.decode(
token, key,
algorithms=["ES256"],
audience="https://api.example.com",
issuer="https://auth.example.com",
leeway=30,
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
Go (golang-jwt/jwt/v5)
tok, err := jwt.Parse(raw, keyFunc, // keyFunc looks up the public key by tok.Header["kid"]
jwt.WithValidMethods([]string{"ES256"}),
jwt.WithIssuer("https://auth.example.com"),
jwt.WithAudience("https://api.example.com"),
jwt.WithExpirationRequired(),
jwt.WithLeeway(30*time.Second),
)
Refresh token rotation with reuse detection
POST /auth/refresh (cookie: rt=<opaque>)
1. row = SELECT ... WHERE token_hash = sha256(rt) FOR UPDATE
2. if no row or row.expires_at < now or row.revoked_at -> 401
3. if row.used_at is not null: # replay of an already-rotated token
UPDATE refresh_tokens SET revoked_at = now WHERE family_id = row.family_id
-> 401 and log a security event (likely theft)
4. UPDATE row SET used_at = now
5. INSERT new row (same family_id, new hash, expires_at = min(now + 30d, family_started + 90d))
6. return new access token + Set-Cookie with the new refresh token
Do steps 1-5 in one transaction. Allow a small grace window (a few seconds) where the previous token returns the same new pair, or parallel tabs and flaky mobile networks will trigger false theft alarms. Logout revokes the family; password change revokes all families for the user.
Revoking access tokens
Pick one, in this order of preference:
- Short expiry only (5-10 min). Revocation latency equals token lifetime. Fine for most apps.
- Per-user
tokens_valid_aftertimestamp checked againstiat(one cached lookup per request). Handles “log out everywhere” and password reset. jtidenylist in Redis with TTL = remaining lifetime. Use for individual compromised tokens. Every verifier must consult it, so this reintroduces a shared dependency.
Key management and rotation
- Keep private keys in a secret manager or KMS; verifiers get only public keys via
/.well-known/jwks.jsonwithCache-Control: max-age=300or similar. - Every key has a
kid. Verifiers select keys bykidand refetch the JWKS (rate-limited) on an unknownkid. - Rotation without logouts:
- Generate key B, publish JWKS containing A and B. Wait at least the JWKS cache TTL.
- Start signing with B.
- Keep A published until the longest-lived token signed with A has expired.
- Remove A.
- Emergency rotation after key compromise: remove A immediately and accept that every session must re-authenticate. Confirm with the user before doing this in production.
Inspecting a token
scripts/jwt_audit.py decodes a token without verifying it and flags alg: none, jku/x5u
headers, missing exp/aud/iss, millisecond timestamps, long lifetimes and claims that look
sensitive. Standard library only, works offline:
python3 scripts/jwt_audit.py "$TOKEN" --max-lifetime 900 \
--expect-iss https://auth.example.com --expect-aud https://api.example.com
Exit 0 means no error findings, 1 means errors, 2 means the input is not a JWS. Never paste production tokens into online decoders; they are live credentials.
Workflow checklist
- Read
AGENTS.md; find existing token code (rg -n "jwt|jsonwebtoken|jose|PyJWT|Bearer") - Confirm JWTs are the right tool (table above)
- Pin algorithm, issuer, audience,
typand leeway in every verifier - Short access lifetime; opaque refresh tokens stored hashed with rotation and reuse detection
- Choose a revocation strategy and implement logout and revoke-all
- Publish JWKS with
kid; document the rotation runbook - Add the negative tests below
Verification
Write tests that must fail closed:
- Token with
alg: none, or HS256 signed with the public key as secret, is rejected. - Wrong
aud, wrongiss, expired by more than leeway, and missingexpare each rejected. - An ID token (or any token without
typ: at+jwt) is rejected by the API. - Reusing a rotated refresh token returns 401 and revokes the newer token in the same family.
- After key rotation, tokens signed with the old key still verify until removal, then fail.
Gotchas
- Using
jwt.decodewithoutalgorithms=in older libraries, orverify=False“for now”. expin milliseconds (Date.now()) makes tokens valid for 50,000 years.- Comparing
audas a string when it may be an array; let the library do it. - Trusting
jku,x5uor an embeddedjwkheader lets attackers supply their own key. - Storing access tokens in
localStorageexposes them to any XSS; keep them in memory and accept a silent refresh on page load. - Scopes and roles baked into a 24 h token survive demotion for 24 h.
- Sharing one signing key across environments means a staging leak forges production tokens.
- For broader token-handling review (storage, leakage in logs, URLs), use
token-security.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "JWT Auth"
short_description: "Short-lived access, rotating refresh, no surprises"
default_prompt: "$jwt-auth Add refresh tokens with rotation to our JWT login and make logout actually work" scripts/jwt_audit.py
#!/usr/bin/env python3
"""Decode a JWT WITHOUT verifying it and flag risky header and claim choices.
Usage:
python3 jwt_audit.py <token> # or '-' to read the token from stdin
python3 jwt_audit.py <token> --max-lifetime 900 --expect-iss https://id.example.com --expect-aud api
Exit codes: 0 = no errors (warnings allowed), 1 = at least one error finding, 2 = not a JWT.
This never proves a token is valid: signature verification must happen in the server code.
"""
import argparse
import base64
import json
import sys
import time
ASYMMETRIC = {"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA", "Ed25519"}
SYMMETRIC = {"HS256", "HS384", "HS512"}
SENSITIVE_HINTS = ("password", "passwd", "secret", "ssn", "card", "cvv", "token", "api_key", "apikey")
def b64url_json(part: str, label: str):
try:
padded = part + "=" * (-len(part) % 4)
return json.loads(base64.urlsafe_b64decode(padded.encode()))
except Exception as exc: # noqa: BLE001 - report any decode failure the same way
raise ValueError(f"{label} is not base64url-encoded JSON ({exc})") from exc
def audit(header: dict, claims: dict, args) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
now = int(time.time())
alg = header.get("alg")
if alg is None or str(alg).lower() == "none":
out.append(("error", "alg is 'none' or missing: verifiers must reject this token"))
elif alg in SYMMETRIC:
out.append(("warn", f"alg {alg} is a shared secret: every verifier can also mint tokens; prefer ES256/EdDSA when several services verify"))
elif alg not in ASYMMETRIC:
out.append(("warn", f"unrecognised alg {alg}"))
if alg in ASYMMETRIC and "kid" not in header:
out.append(("warn", "no kid header: key rotation will require a flag day"))
for risky in ("jku", "x5u", "jwk"):
if risky in header:
out.append(("error", f"header carries '{risky}': never fetch or trust keys named by the token itself"))
if "crit" in header:
out.append(("warn", f"crit header present {header['crit']}: verifier must understand every listed extension"))
exp, iat, nbf = claims.get("exp"), claims.get("iat"), claims.get("nbf")
if exp is None:
out.append(("error", "no exp claim: token never expires"))
elif not isinstance(exp, (int, float)):
out.append(("error", "exp is not a NumericDate (seconds since epoch)"))
else:
if exp > 10**11:
out.append(("error", "exp looks like milliseconds, not seconds"))
if exp < now:
out.append(("info", f"token expired {now - int(exp)} s ago"))
if isinstance(iat, (int, float)):
life = int(exp - iat)
level = "warn" if life > args.max_lifetime else "info"
out.append((level, f"lifetime {life} s (threshold {args.max_lifetime} s)"))
if iat is None:
out.append(("warn", "no iat claim: cannot reason about token age or revoke-before timestamps"))
elif isinstance(iat, (int, float)) and iat > now + 60:
out.append(("warn", "iat is in the future: clock skew or forged token"))
if isinstance(nbf, (int, float)) and nbf > now + 60:
out.append(("info", "nbf is in the future: token not yet valid"))
for claim in ("iss", "aud", "sub"):
if claim not in claims:
out.append(("warn", f"no {claim} claim"))
if "jti" not in claims:
out.append(("info", "no jti: individual tokens cannot be put on a denylist"))
if args.expect_iss and claims.get("iss") != args.expect_iss:
out.append(("error", f"iss {claims.get('iss')!r} != expected {args.expect_iss!r}"))
if args.expect_aud:
aud = claims.get("aud")
auds = aud if isinstance(aud, list) else [aud]
if args.expect_aud not in auds:
out.append(("error", f"aud {aud!r} does not include {args.expect_aud!r}"))
for key in claims:
if any(h in key.lower() for h in SENSITIVE_HINTS):
out.append(("warn", f"claim '{key}' looks sensitive: JWT payloads are readable by anyone holding the token"))
return out
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("token", help="JWT string, or '-' for stdin")
p.add_argument("--max-lifetime", type=int, default=900, help="warn when exp - iat exceeds this many seconds (default 900)")
p.add_argument("--expect-iss")
p.add_argument("--expect-aud")
args = p.parse_args()
token = sys.stdin.read().strip() if args.token == "-" else args.token.strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
parts = token.split(".")
if len(parts) == 5:
print("This is a JWE (encrypted token); payload cannot be inspected without the key.")
return 2
if len(parts) != 3:
print(f"Not a JWS compact token: expected 3 dot-separated parts, got {len(parts)}")
return 2
try:
header = b64url_json(parts[0], "header")
claims = b64url_json(parts[1], "payload")
except ValueError as exc:
print(exc)
return 2
print("header:", json.dumps(header, indent=2, sort_keys=True))
print("claims:", json.dumps(claims, indent=2, sort_keys=True))
print("signature present:", bool(parts[2]), "(NOT verified)")
findings = audit(header, claims, args)
print()
for level, msg in findings:
print(f"[{level}] {msg}")
errors = sum(1 for lvl, _ in findings if lvl == "error")
warns = sum(1 for lvl, _ in findings if lvl == "warn")
print(f"\n{errors} error(s), {warns} warning(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
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.