JWT Handling skill for Claude Code
Verify, never just decode. Claude verifies every JWT with an explicit algorithm allowlist and required claims using jose, PyJWT or golang-jwt, issues short-lived tokens with kid headers, and rotates keys through JWKS without downtime. It audits code for decode-without-verify and algorithm confusion, and ships a standard-library inspector that flags risky tokens without ever claiming to verify them.
- name
jwt-handling - size173 lines · ≈1,800 tokens
- files2
- 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: Issues and validates JSON Web Tokens safely — algorithm allowlists, signature verification with JWKS and kid, required claims (exp, iss, aud, nbf), short lifetimes with refresh rotation, key rotation, browser storage choices, and revocation strategies — and audits existing JWT code for alg confusion, none, and decode-without-verify bugs. Use when code signs or verifies JWTs, uses jose, PyJWT, jsonwebtoken, or golang-jwt, when designing token expiry or logout, or when a token is pasted for inspection. Try asking
- Review how our API verifies JWTs and fix anything unsafe
- Design token expiry and logout for our mobile app's JWT auth
- Here's a token from our staging logs — what's wrong with it?
Use it when
- Code signs or verifies JWTs with jose, PyJWT, jsonwebtoken, or golang-jwt
- Designing access and refresh token lifetimes or logout behavior
- Rotating signing keys or publishing a JWKS endpoint
- Inspecting a suspicious token during debugging or review
Skip it when
- You are implementing the full OAuth or OIDC login flow — use oauth-oidc instead
- A plain server-side session would serve a single web app better — use auth-sessions instead
How to check the result
Unit tests reject tokens with a bad signature, alg none, HS256 signed with the RSA public key, expired exp, wrong iss, wrong aud, and unknown kid, while a valid token passes.
The common mistake it prevents
Calling a decode function that skips signature checks, or verifying without an algorithm allowlist, so a forged or unsigned token is accepted as genuine.
Done well means: tokens are verified (never just decoded) with a pinned algorithm and the right key, every required claim is checked, lifetimes are short, keys rotate without downtime, and logout/revocation has an explicit answer. If you need instant revocation and only one backend consumes the token, reconsider: an opaque session ID may be simpler (see auth-sessions).
Hard rules
- Always pass an explicit algorithm allowlist to the verify call. Never derive the algorithm from the token header alone.
- Never accept
alg: none. Never let an asymmetric public key be used as an HMAC secret (algorithm confusion: RS256 key accepted as HS256). decodewithout verification is for debugging only. Grep for it in production code.- Require
exp. Checkissandaudon every verification. Allow ≤ 60 s clock skew. - Do not put secrets or sensitive PII in the payload: JWS payloads are base64url, not encrypted. Anyone holding the token can read them.
- Never fetch keys from a URL in the token (
jku,x5u) or trust an embeddedjwkheader. Keys come from configuration or the issuer’s known JWKS endpoint.
Choosing algorithms and keys
| Situation | Algorithm |
|---|---|
| One service issues and verifies | HS256 with a ≥ 256-bit random secret is acceptable; EdDSA/ES256 if others may verify later |
| Issuer and verifiers are different services | Asymmetric: EdDSA (Ed25519) or ES256; RS256/PS256 for broad compatibility (≥ 2048-bit RSA) |
| Third-party IdP tokens | Whatever the IdP publishes in JWKS, pinned to that list |
| Payload must be confidential | JWE, or do not put the data in the token |
Verify correctly
Node (npm i jose):
import { createRemoteJWKSet, jwtVerify } from "jose";
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"],
clockTolerance: 30,
requiredClaims: ["exp", "sub"],
});
return payload;
}
Python (pip install "pyjwt[crypto]"):
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=["RS256"],
audience="https://api.example.com",
issuer="https://auth.example.com/",
leeway=30,
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
Go (github.com/golang-jwt/jwt/v5):
tok, err := jwt.Parse(raw, keyFunc,
jwt.WithValidMethods([]string{"ES256"}),
jwt.WithIssuer("https://auth.example.com/"),
jwt.WithAudience("https://api.example.com"),
jwt.WithExpirationRequired(),
jwt.WithLeeway(30*time.Second),
)
Node jsonwebtoken: always pass { algorithms: [...], audience, issuer } to
jwt.verify; jwt.decode does no verification.
After signature and claims pass, authorization is still separate: check scopes/roles for the operation and object-level ownership.
Issue correctly
- Claims:
iss,sub(stable user ID, not email),aud(the intended API),exp,iat,jti(if you need revocation or replay detection), minimal custom claims. - Header:
alg,kid(always — needed for rotation),typ(at+jwtfor access tokens per RFC 9068 helps prevent token confusion). - Lifetimes: access tokens 5–15 minutes; refresh tokens hours to days, rotated on use, stored server-side or as opaque values with a DB record.
- Keep tokens small; they ride on every request and cookies cap near 4 KB.
Key rotation without downtime
- Generate the new key pair; publish its public key in JWKS alongside the old (new
kid). - Wait for verifier JWKS caches to refresh (cache TTL, typically ≤ 1 hour).
- Start signing with the new key.
- Keep the old public key published until the longest-lived token signed with it expires.
- Remove the old key. For HS256, verifiers must accept both secrets during the overlap.
Verifiers should refetch JWKS on unknown kid (rate limited), which jose and
PyJWKClient do.
Storage in browsers
| Option | XSS impact | CSRF | Verdict |
|---|---|---|---|
localStorage / sessionStorage | Token stolen and usable anywhere | None | Avoid for anything sensitive |
| JS memory + refresh via HttpOnly cookie | Token usable only while XSS runs; refresh cookie not readable | Protect refresh endpoint | Acceptable for SPAs without a backend |
HttpOnly Secure SameSite cookie (BFF) | Not readable by script | Needs CSRF defenses | Preferred |
Revocation strategies
| Need | Approach |
|---|---|
| Logout on this device | Delete refresh token server-side; access token dies at exp (keep it short) |
| Logout everywhere / password change | Per-user token_version (or sessions_valid_after timestamp) checked on refresh; optionally on each request via cache |
| Kill a specific stolen token now | Denylist jti in Redis until its exp |
| Compromised signing key | Rotate key immediately and remove old kid from JWKS; all tokens signed with it fail |
If every request must check a store to support revocation, you have rebuilt sessions; that is fine, but choose it deliberately.
Auditing existing code
grep -rnE "jwt\.decode\(|jsonwebtoken.*decode|jwt_decode|verify_signature\"?\s*:\s*False|verify\s*=\s*False" --include=*.{js,ts,py,go,java,rb} .
grep -rnE "algorithms\s*[:=]" --include=*.{js,ts,py} . # confirm explicit allowlists
grep -rnE "jwt\.verify\([^,]+,[^,]+\)" --include=*.{js,ts} . # verify without options
grep -rnE "localStorage\.(setItem|getItem)\(['\"](token|jwt|access)" --include=*.{js,ts,tsx} .
Inspect a token someone pasted (decodes only — never treat this as verification):
python3 scripts/jwt_inspect.py "<token>"
python3 scripts/jwt_inspect.py --file token.txt --max-lifetime 3600
The script prints header and payload, human-readable times, and flags: alg none or
symmetric, jku/x5u/jwk/suspicious kid headers, missing exp/iss/aud, expired
tokens, lifetimes above the threshold, and sensitive-looking claim names. It exits 1 when
any high-risk flag is raised. Ask before pasting production tokens anywhere; they are
live credentials until exp.
Gotchas
audcan be a string or array; libraries handle both, hand-written checks often do not.- Clock skew between services causes intermittent
exp/nbffailures; sync NTP and use small leeway rather than large tolerances. kidused as a file path or SQL key in custom key lookup is an injection point.- Base64url, not base64:
-and_, no padding. Hand decoders break on this. - Long-lived JWTs “because refresh is complicated” are the most common real-world flaw.
Verification
Write unit tests that build tokens locally and assert rejection for: wrong signature,
alg: none, HS256 signed with the RSA public key, expired exp, future nbf, wrong
iss, wrong aud, unknown kid. Assert a valid token passes. Run the audit greps and
confirm every decode without verification is test or debug code.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/jwt_inspect.py
#!/usr/bin/env python3
"""Decode a JWT (WITHOUT verifying the signature) and flag risky properties.
Usage:
python3 jwt_inspect.py "<token>"
python3 jwt_inspect.py --file token.txt [--max-lifetime SECONDS]
Exit codes: 0 = no high-risk flags, 1 = high-risk flags found, 2 = input error.
Standard library only (Python 3.10+).
"""
import argparse
import base64
import json
import re
import sys
from datetime import datetime, timezone
SENSITIVE = re.compile(r"pass(word)?|secret|ssn|card|cvv|private|api[_-]?key|token", re.I)
def b64url_decode(part: str) -> bytes:
part += "=" * (-len(part) % 4)
return base64.urlsafe_b64decode(part.encode())
def fmt_ts(value):
try:
return datetime.fromtimestamp(int(value), tz=timezone.utc).isoformat()
except (TypeError, ValueError, OverflowError, OSError):
return f"invalid ({value!r})"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("token", nargs="?", help="compact JWS/JWE string")
ap.add_argument("--file", help="read token from file")
ap.add_argument("--max-lifetime", type=int, default=86400,
help="flag tokens whose exp - iat exceeds this many seconds (default 86400)")
args = ap.parse_args()
raw = open(args.file).read() if args.file else args.token
if not raw:
ap.print_usage(sys.stderr)
return 2
raw = raw.strip().removeprefix("Bearer ").strip()
parts = raw.split(".")
if len(parts) == 5:
print("This is a JWE (encrypted token); only the header is readable.")
elif len(parts) != 3:
print(f"error: expected 3 dot-separated parts, got {len(parts)}", file=sys.stderr)
return 2
try:
header = json.loads(b64url_decode(parts[0]))
except Exception as exc: # noqa: BLE001
print(f"error: header is not base64url JSON: {exc}", file=sys.stderr)
return 2
high, warn = [], []
print("HEADER")
print(json.dumps(header, indent=2))
alg = str(header.get("alg", ""))
if alg.lower() == "none" or not alg:
high.append("alg is 'none' or missing: unsigned token")
elif alg.startswith("HS"):
warn.append(f"alg {alg} is symmetric: every verifier holds the signing secret")
for h in ("jku", "x5u", "jwk"):
if h in header:
high.append(f"header '{h}' present: verifiers must never fetch or trust keys from the token")
kid = header.get("kid")
if kid is None:
warn.append("no 'kid' header: key rotation will be painful")
elif re.search(r"[./\\'\";]|\.\.", str(kid)):
high.append(f"kid {kid!r} contains path/SQL metacharacters: check key lookup for injection")
if len(parts) == 5:
_report(high, warn)
return 1 if high else 0
try:
payload = json.loads(b64url_decode(parts[1]))
except Exception as exc: # noqa: BLE001
print(f"error: payload is not base64url JSON: {exc}", file=sys.stderr)
return 2
print("\nPAYLOAD")
print(json.dumps(payload, indent=2))
print("\nTIMES (UTC)")
now = datetime.now(tz=timezone.utc).timestamp()
for claim in ("iat", "nbf", "exp"):
if claim in payload:
print(f" {claim}: {fmt_ts(payload[claim])}")
if "exp" not in payload:
high.append("no 'exp' claim: token never expires")
else:
try:
if float(payload["exp"]) < now:
warn.append("token is expired")
if "iat" in payload and float(payload["exp"]) - float(payload["iat"]) > args.max_lifetime:
warn.append(f"lifetime exp-iat = {int(float(payload['exp']) - float(payload['iat']))}s exceeds {args.max_lifetime}s")
except (TypeError, ValueError):
high.append("exp/iat are not numeric")
for claim in ("iss", "aud", "sub"):
if claim not in payload:
warn.append(f"no '{claim}' claim: verifiers cannot check it")
for key in payload:
if SENSITIVE.search(key):
warn.append(f"claim '{key}' looks sensitive: JWT payloads are readable by anyone holding the token")
sig = parts[2]
if not sig:
high.append("empty signature segment")
_report(high, warn)
print("\nNOTE: decoded only. Signature, issuer and audience were NOT verified.")
return 1 if high else 0
def _report(high, warn):
print("\nFINDINGS")
if not high and not warn:
print(" none")
for item in high:
print(f" [HIGH] {item}")
for item in warn:
print(f" [WARN] {item}")
if __name__ == "__main__":
sys.exit(main())
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.