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.

  • Backend & APIs
  • $jwt-auth
  • 196 lines
  • 3 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- jwt-auth

Installs to ~/.agents/skills/jwt-auth/ 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 $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.

~/.agents/skills/jwt-auth/SKILL.md

--- name: jwt-auth description: Designs and hardens JWT bearer authentication - short-lived access tokens, opaque rotating refresh tokens with reuse detection, revocation… license: MIT ---

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

SituationChoice
Browser app on the same site as its APIPrefer session-auth; JWTs add revocation problems for no gain
Mobile app, CLI, or several APIs verifying one loginShort 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 callsOAuth 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

TokenFormatLifetimeStorage (browser)Storage (server)
AccessJWT, asymmetric signature5-15 minMemory onlyNothing (stateless)
RefreshOpaque 256-bit random string7-30 d sliding, 90 d absolute capHttpOnly; Secure; SameSite=Strict cookie scoped to Path=/auth/refreshsha256(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:

  1. Short expiry only (5-10 min). Revocation latency equals token lifetime. Fine for most apps.
  2. Per-user tokens_valid_after timestamp checked against iat (one cached lookup per request). Handles “log out everywhere” and password reset.
  3. jti denylist 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.json with Cache-Control: max-age=300 or similar.
  • Every key has a kid. Verifiers select keys by kid and refetch the JWKS (rate-limited) on an unknown kid.
  • Rotation without logouts:
    1. Generate key B, publish JWKS containing A and B. Wait at least the JWKS cache TTL.
    2. Start signing with B.
    3. Keep A published until the longest-lived token signed with A has expired.
    4. 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, typ and 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, wrong iss, expired by more than leeway, and missing exp are 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.decode without algorithms= in older libraries, or verify=False “for now”.
  • exp in milliseconds (Date.now()) makes tokens valid for 50,000 years.
  • Comparing aud as a string when it may be an array; let the library do it.
  • Trusting jku, x5u or an embedded jwk header lets attackers supply their own key.
  • Storing access tokens in localStorage exposes 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.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: "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.py123 lines, 5.6 KB
#!/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.

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