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.

  • namejwt-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.

~/.claude/skills/jwt-handling/SKILL.md

--- name: jwt-handling description: Issues and validates JSON Web Tokens safely — algorithm allowlists, signature verification with JWKS and kid, required claims (exp, iss,… license: MIT ---

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).
  • decode without verification is for debugging only. Grep for it in production code.
  • Require exp. Check iss and aud on 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 embedded jwk header. Keys come from configuration or the issuer’s known JWKS endpoint.

Choosing algorithms and keys

SituationAlgorithm
One service issues and verifiesHS256 with a ≥ 256-bit random secret is acceptable; EdDSA/ES256 if others may verify later
Issuer and verifiers are different servicesAsymmetric: EdDSA (Ed25519) or ES256; RS256/PS256 for broad compatibility (≥ 2048-bit RSA)
Third-party IdP tokensWhatever the IdP publishes in JWKS, pinned to that list
Payload must be confidentialJWE, 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+jwt for 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

  1. Generate the new key pair; publish its public key in JWKS alongside the old (new kid).
  2. Wait for verifier JWKS caches to refresh (cache TTL, typically ≤ 1 hour).
  3. Start signing with the new key.
  4. Keep the old public key published until the longest-lived token signed with it expires.
  5. 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

OptionXSS impactCSRFVerdict
localStorage / sessionStorageToken stolen and usable anywhereNoneAvoid for anything sensitive
JS memory + refresh via HttpOnly cookieToken usable only while XSS runs; refresh cookie not readableProtect refresh endpointAcceptable for SPAs without a backend
HttpOnly Secure SameSite cookie (BFF)Not readable by scriptNeeds CSRF defensesPreferred

Revocation strategies

NeedApproach
Logout on this deviceDelete refresh token server-side; access token dies at exp (keep it short)
Logout everywhere / password changePer-user token_version (or sessions_valid_after timestamp) checked on refresh; optionally on each request via cache
Kill a specific stolen token nowDenylist jti in Redis until its exp
Compromised signing keyRotate 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

  • aud can be a string or array; libraries handle both, hand-written checks often do not.
  • Clock skew between services causes intermittent exp/nbf failures; sync NTP and use small leeway rather than large tolerances.
  • kid used 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 132 lines · 4.5 KB
#!/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.

esc