Use Cases (Updated: 6/2/2026)

JWT Authentication with Claude Code: Safe Claims, Cookies, Rotation, and Keys

Build JWT auth with Claude Code: claims, cookies, refresh rotation, revocation, key rotation, and safe prompts.

JWT Authentication with Claude Code: Safe Claims, Cookies, Rotation, and Keys

JWT authentication looks simple because a demo can fit in a few lines. Sign a payload, return a token, verify it in middleware, and the API feels stateless. The production version is less forgiving. A token placed in the wrong storage, a missing aud check, a long-lived refresh token, or a rushed key rotation can become an account takeover path.

This guide explains JWT authentication for beginners, then turns it into a Claude Code implementation brief. It covers claim design, signing versus encryption, cookie and session placement, refresh-token rotation, revocation, key rotation, common security failures, and prompts that keep Claude Code away from vague or unsafe shortcuts. For the surrounding login layer, pair it with authentication implementation, cookie management, and role-based access control.

Use primary sources while adapting the code: RFC 7519 for JWT, RFC 8725 for JWT best current practices, RFC 9700 for OAuth refresh-token replay guidance, the OWASP JWT Cheat Sheet, MDN Set-Cookie, jose, and Claude Code settings.

JWT Basics for Beginners

A JWT is a string made of three base64url-encoded parts: header.payload.signature. The header names the token type and signing algorithm. The payload contains claims, which are statements about the token. The signature lets the verifier detect tampering.

The critical beginner point is that a normal JWT is signed, not encrypted. In most web apps, the token is a JWS. Anyone who obtains it can decode the payload. The signature proves the payload was not changed; it does not hide email addresses, plan names, API keys, billing details, or internal notes. If the data must be secret, do not put it in the JWT payload. Use a server-side session lookup, or consider JWE only when encryption is truly required.

Start Claude Code with the security model, not just the feature request.

Implement JWT authentication.
Rules:
- JWT payloads are signed, not encrypted. Do not put secrets in them.
- Access tokens expire in 15 minutes or less.
- Refresh tokens expire in 7 days or less.
- Validate iss, aud, sub, exp, iat, and jti.
- Reject alg none and any unexpected algorithm.
- Implement refresh-token rotation and reuse detection.
- Review Cookie attributes, CSRF, XSS, revocation, and key rotation.

Design Minimal Claims

Treat a JWT as an entry badge, not as a user profile cache. Access tokens should contain only the data the API needs before it can safely load the current account state. Good starting claims are a stable user id, issuer, audience, expiration, token id, session id, tenant id, and a coarse role.

ClaimPurposeProduction caution
subStable user idPrefer an internal id over an email address
issIssuerPin it to your auth server
audIntended audienceReject tokens meant for another API
expExpirationKeep access tokens short-lived
jtiToken idUse it for revocation and audit trails
sidSession idUse it for device logout and token families
roleCoarse roleRecheck fine-grained authorization on the server

Do not put plan: "pro", disabled: false, or a long permission list into the token unless you accept stale authorization. Plans change, accounts get suspended, and tenant memberships move. Authentication answers “who is this?” Authorization answers “may this user perform this action now?” Keep the second question close to your database or policy engine.

Copy-Paste TypeScript with jose

The following demo signs and verifies access tokens, stores only refresh-token hashes, rotates refresh tokens, and detects reuse. For production, replace the in-memory Map with Redis or a database, add rate limiting, add audit logs, and serve it only over HTTPS.

mkdir jwt-lab
cd jwt-lab
npm init -y
npm install jose
npm install -D tsx typescript @types/node
// auth-demo.ts
import { createHash, createSecretKey, randomUUID } from "node:crypto";
import { SignJWT, jwtVerify } from "jose";

const ISSUER = "https://auth.example.com";
const AUDIENCE = "claudecodelab-api";
const ACCESS_TTL = "15m";
const REFRESH_TTL_SECONDS = 60 * 60 * 24 * 7;

const accessKey = createSecretKey(
  Buffer.from(
    process.env.JWT_ACCESS_SECRET ??
      "dev-only-secret-change-me-32-bytes-minimum"
  )
);

const refreshKey = createSecretKey(
  Buffer.from(
    process.env.JWT_REFRESH_SECRET ??
      "dev-only-refresh-secret-change-me-32-bytes"
  )
);

type Role = "admin" | "user" | "viewer";
type User = { id: string; role: Role; tenantId: string };
type VerifiedAccess = {
  userId: string;
  role: Role;
  tenantId: string;
  sessionId: string;
  tokenId: string;
};

type RefreshRecord = {
  userId: string;
  sessionId: string;
  tokenHash: string;
  expiresAt: number;
  revokedAt?: number;
};

const refreshStore = new Map<string, RefreshRecord>();
const revokedAccessTokenIds = new Set<string>();

function sha256(value: string) {
  return createHash("sha256").update(value).digest("hex");
}

function assertRole(value: unknown): asserts value is Role {
  if (!["admin", "user", "viewer"].includes(String(value))) {
    throw new Error("invalid role claim");
  }
}

async function signAccessToken(user: User, sessionId: string) {
  const tokenId = randomUUID();

  return new SignJWT({ role: user.role, tid: user.tenantId, sid: sessionId })
    .setProtectedHeader({ alg: "HS256", typ: "JWT" })
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setSubject(user.id)
    .setIssuedAt()
    .setExpirationTime(ACCESS_TTL)
    .setJti(tokenId)
    .sign(accessKey);
}

async function verifyAccessToken(token: string): Promise<VerifiedAccess> {
  const { payload } = await jwtVerify(token, accessKey, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ["HS256"],
  });

  assertRole(payload.role);

  if (
    typeof payload.sub !== "string" ||
    typeof payload.tid !== "string" ||
    typeof payload.sid !== "string" ||
    typeof payload.jti !== "string"
  ) {
    throw new Error("missing required claim");
  }

  if (revokedAccessTokenIds.has(payload.jti)) {
    throw new Error("access token revoked");
  }

  return {
    userId: payload.sub,
    role: payload.role,
    tenantId: payload.tid,
    sessionId: payload.sid,
    tokenId: payload.jti,
  };
}

async function signRefreshToken(user: User, sessionId: string) {
  const tokenId = randomUUID();
  const token = await new SignJWT({ sid: sessionId, kind: "refresh" })
    .setProtectedHeader({ alg: "HS256", typ: "JWT" })
    .setIssuer(ISSUER)
    .setAudience("claudecodelab-refresh")
    .setSubject(user.id)
    .setIssuedAt()
    .setExpirationTime("7d")
    .setJti(tokenId)
    .sign(refreshKey);

  refreshStore.set(tokenId, {
    userId: user.id,
    sessionId,
    tokenHash: sha256(token),
    expiresAt: Date.now() + REFRESH_TTL_SECONDS * 1000,
  });

  return token;
}

async function rotateRefreshToken(refreshToken: string, user: User) {
  const { payload } = await jwtVerify(refreshToken, refreshKey, {
    issuer: ISSUER,
    audience: "claudecodelab-refresh",
    algorithms: ["HS256"],
  });

  if (
    typeof payload.jti !== "string" ||
    typeof payload.sid !== "string" ||
    typeof payload.sub !== "string"
  ) {
    throw new Error("invalid refresh token claims");
  }

  const record = refreshStore.get(payload.jti);
  const presentedHash = sha256(refreshToken);

  if (!record || record.revokedAt || record.tokenHash !== presentedHash) {
    for (const item of refreshStore.values()) {
      if (item.sessionId === payload.sid) item.revokedAt = Date.now();
    }
    throw new Error("refresh token reuse detected");
  }

  if (record.expiresAt < Date.now()) {
    throw new Error("refresh token expired");
  }

  record.revokedAt = Date.now();

  return {
    accessToken: await signAccessToken(user, payload.sid),
    refreshToken: await signRefreshToken(user, payload.sid),
  };
}

async function main() {
  const user: User = {
    id: "user_123",
    role: "admin",
    tenantId: "tenant_a",
  };
  const sessionId = randomUUID();
  const accessToken = await signAccessToken(user, sessionId);
  const refreshToken = await signRefreshToken(user, sessionId);
  const verified = await verifyAccessToken(accessToken);
  const rotated = await rotateRefreshToken(refreshToken, user);

  console.log({ verified, rotatedRefreshLength: rotated.refreshToken.length });
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});
npx tsx auth-demo.ts

The important detail is that the refresh token itself is not stored. Only its hash is stored. When a refresh token is used, the record is revoked and a new pair is issued. If an old refresh token appears again, the code revokes the whole token family for that sid. That is the practical shape of refresh-token rotation with reuse detection.

For browser apps, store the refresh token in an HttpOnly, Secure, SameSite cookie. HttpOnly means JavaScript cannot read the cookie directly, which reduces token theft during XSS. It does not remove the need for XSS prevention. Cookies are sent automatically, so state-changing refresh and logout routes still need CSRF defenses such as SameSite, CSRF tokens, and Origin or Referer checks.

const refreshCookieOptions = {
  httpOnly: true,
  secure: true,
  sameSite: "lax" as const,
  path: "/api/auth/refresh",
  maxAge: 60 * 60 * 24 * 7,
};

const clearRefreshCookieOptions = {
  ...refreshCookieOptions,
  maxAge: 0,
};

Access-token placement depends on the architecture. A pure SPA can keep the access token in memory, accepting that a reload requires refresh. A BFF or Next.js Route Handler can avoid exposing access tokens to the browser and proxy API calls server-side. localStorage is convenient, but any XSS can read it, so avoid long-lived bearer tokens there.

Revocation and Key Rotation

JWTs remain valid until exp unless your server adds another control. Use short access-token lifetimes, a jti revocation list for high-risk events, session-level invalidation through sid, and refresh-token rotation. Logging out should revoke the refresh record. Password changes, account suspension, and suspected compromise should revoke every session for that account.

For key rotation, start with a written runbook. HS256 is simple but every verifier needs the shared secret. As services multiply, RS256 or ES256 with JWKS is easier to operate because verifiers only need public keys. Put kid in the protected header, publish old and new public keys at the same time, sign new tokens with the new key, then remove the old public key after the longest relevant token lifetime has passed.

import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://auth.example.com/.well-known/jwks.json")
);

export async function verifyWithRotatingKeys(token: string) {
  return jwtVerify(token, JWKS, {
    issuer: "https://auth.example.com",
    audience: "claudecodelab-api",
    algorithms: ["RS256", "ES256"],
  });
}
{
  "rotationPlan": {
    "step1": "Generate a new key and publish it in JWKS",
    "step2": "Start signing new tokens with the new kid",
    "step3": "Keep the old public key until old access tokens expire",
    "step4": "Review logs, then remove the old key"
  }
}

Use Cases That Change the Design

flowchart LR
  Login["Login"] --> Access["Short-lived access token"]
  Login --> Refresh["HttpOnly refresh cookie"]
  Access --> API["API verifies iss/aud/exp/jti"]
  Refresh --> Rotate["Rotate on refresh"]
  Rotate --> Store["Store hash and sid in DB/Redis"]
  Store --> Reuse["Reuse detected: revoke token family"]

Use case 1: SaaS admin panels. Include tenantId as a claim for routing and audit context, but do not trust it alone. Every database query that touches tenant data still needs a tenant condition. Recheck billing status, account suspension, and admin rights server-side for destructive operations.

Use case 2: paid content and course sites. Keep access tokens short and refresh quietly so readers are not logged out mid-lesson. Because ads, analytics, and purchase CTAs may share the site, connect this with web security headers and cookie consent work instead of treating auth as an isolated file.

Use case 3: mobile and desktop apps. Use secure OS storage rather than browser cookies. Keep sid so a lost device can be revoked. Log refresh-token reuse as a security event, not just as a generic 401.

Use case 4: microservices. Do not distribute symmetric signing secrets everywhere. Prefer public-key verification, a gateway, or a token exchange pattern. Every service should check aud and reject tokens that were not issued for it.

Common Failures and Fixes

FailureResultFix
Algorithm not pinnedConfusion or weak verificationPass an explicit algorithm allowlist
Secrets in payloadLogs and browsers expose themStore only ids and minimal claims
Missing aud or issTokens cross API boundariesValidate issuer and audience
Reused refresh tokenStolen token survives too longRotate and detect reuse
Long-lived token in localStorageXSS steals the bearer tokenUse HttpOnly cookies or BFF design
Logout only clears the browserServer token remains validRevoke sid and refresh records
Old key removed immediatelyActive users get mass 401sKeep a JWKS overlap window
Secrets pasted into Claude CodeSecret appears in chat contextShare variable names and constraints only

Safe Claude Code Prompts

Ask Claude Code to inspect before it edits.

Design and implement JWT authentication in this repository.
Before editing, produce a table with:
- Framework, user model, session/cookie code, auth middleware
- Existing authorization checks, CSRF defenses, CSP, and rate limits
- Current token storage location and XSS/CSRF risk

Implementation rules:
- Use jose. Do not switch back to jsonwebtoken.
- Access token: 15 minutes. Refresh token: 7 days.
- Validate iss, aud, sub, exp, iat, jti, and sid.
- Store only a hash of the refresh token in DB/Redis.
- Rotate refresh tokens and revoke the sid family on reuse.
- Do not print secrets, .env files, or production tokens.
- End with test or curl verification evidence.

Then run a review-only prompt.

Review this JWT authentication diff for security issues.
Focus on:
- alg none, missing aud/iss checks, and excessive exp values
- PII or secrets in JWT payloads
- refresh-token rotation and reuse detection
- HttpOnly, Secure, SameSite, and cookie path settings
- logout, password-change, and account-suspension revocation
- missing tests or manual verification steps

Verification, CTA, and Practical Result

Verify success and failure paths. A login test is not enough.

curl -i -X POST https://example.com/api/auth/login \
  -H "content-type: application/json" \
  -d '{"email":"demo@example.com","password":"correct horse"}'

npm test -- --runInBand auth

Test expired access tokens, tampered signatures, wrong audiences, revoked jti values, reused refresh tokens, logout, password change, and account suspension. Also confirm that refresh cookies are HttpOnly, Secure, have the intended SameSite, and are scoped to the refresh path when possible.

If you are standardizing auth in a real repository, start with the free Claude Code cheatsheet to make verification habits repeatable. For reusable prompts and setup material, use the ClaudeCodeLab products. For a team rollout covering JWT, RBAC, cookies, audit logs, and CI gates, use Claude Code training and consultation.

When I tested the demo behind this article, the most useful step was writing the claim table before writing the signing code. It exposed three issues early: a missing aud check, a top-level await example that did not run under the default tsx path, and the risk of storing refresh tokens in plain text. Giving Claude Code the claims, storage decision, revocation rules, and verification commands up front reduces rework sharply.

JWT authentication is not hard to start, but it is easy to finish too early. A production-ready design includes minimal claims, explicit verification, safe storage, refresh rotation, revocation, key rotation, and a review prompt that refuses vague shortcuts.

#Claude Code #JWT #authentication #security #Node.js
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.