Autentikasi JWT dengan Claude Code: claims, cookie, rotasi, dan key
Bangun JWT auth dengan Claude Code: claims, cookie, refresh rotation, revocation, key rotation, dan prompt aman.
Autentikasi JWT terlihat sederhana saat masih berupa demo. Sign payload, kirim token, lalu verify di middleware. Di production, detail kecil bisa menjadi celah besar: aud tidak dicek, refresh token terlalu panjang, token disimpan di localStorage, atau key rotation dilakukan tanpa masa transisi.
Panduan ini menjelaskan JWT dari dasar, lalu mengubahnya menjadi brief yang jelas untuk Claude Code. Kita akan membahas claim design, perbedaan signing dan encryption, penempatan di Cookie atau session, refresh-token rotation, revocation, key rotation, failure mode umum, dan prompt aman. Untuk konteks login lengkap, baca juga implementasi autentikasi, cookie management, dan RBAC.
Gunakan sumber utama saat menyesuaikan kode: RFC 7519 untuk JWT, RFC 8725 untuk praktik aman, RFC 9700 untuk replay dan refresh token, OWASP JWT Cheat Sheet, MDN Set-Cookie, jose, dan Claude Code settings.
Dasar JWT untuk pemula
JWT adalah string dengan tiga bagian: header.payload.signature. Header berisi tipe dan algoritma. Payload berisi claims, yaitu pernyataan tentang token. Signature membantu mendeteksi perubahan. Claim yang sering dipakai adalah sub untuk user id, iss untuk issuer, aud untuk audience, exp untuk expiry, dan jti untuk token id.
Hal terpenting: JWT biasa ditandatangani, bukan dienkripsi. Signature membuktikan payload tidak diubah, tetapi tidak menyembunyikan payload. Siapa pun yang memegang token bisa membaca isinya. Jangan masukkan API key, alamat, data pembayaran, catatan internal, atau data pribadi yang tidak perlu. Jika data harus rahasia, simpan di server dan masukkan referensi minimal ke JWT.
Berikan batas ini ke Claude Code sejak awal.
Implementasikan autentikasi JWT.
Aturan:
- Payload JWT ditandatangani, bukan dienkripsi. Jangan isi secrets.
- Access token maksimal 15 menit.
- Refresh token maksimal 7 hari.
- Validasi iss, aud, sub, exp, iat, dan jti.
- Tolak alg none dan algoritma yang tidak diharapkan.
- Implementasikan refresh-token rotation dan reuse detection.
- Review Cookie, CSRF, XSS, revocation, dan key rotation.
Claim harus minimal
JWT bukan cache profil user. Access token hanya perlu data minimum untuk pintu masuk API: user id stabil, session id, tenant id, role kasar, dan token id. Data seperti plan, status suspend, permission detail, quota, atau billing perlu dicek ulang di server karena sering berubah.
| Claim | Tujuan | Catatan |
|---|---|---|
sub | User id | Pakai ID internal, bukan email |
iss | Issuer | Kunci ke auth server |
aud | Audience | Tolak token untuk API lain |
exp | Expiry | Access token harus pendek |
jti | Token id | Revocation dan audit |
sid | Session id | Logout per device dan token family |
role | Role kasar | Authorization detail dicek server |
Kesalahan umum adalah memasukkan plan: "pro" ataudisabled: false ke token. Plan bisa berubah dan akun bisa dibekukan, tetapi token tetap membawa claim lama sampai expired. Authentication menjawab “siapa user ini?”. Authorization menjawab “boleh melakukan aksi ini sekarang?”. Pisahkan dua keputusan itu.
TypeScript runnable dengan jose
Demo berikut sign dan verify access token, menyimpan hash refresh token saja, melakukan refresh token rotation, dan mendeteksi reuse. Untuk production, ganti Map dengan Redis atau database, lalu tambahkan HTTPS, rate limit, audit log, dan CSRF protection.
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
Detail pentingnya adalah refresh token mentah tidak disimpan. Yang disimpan hanya hash. Setelah dipakai, record lama direvoke dan pasangan token baru diterbitkan. Jika token lama muncul lagi, seluruh family dengan sid yang sama direvoke.
Cookie, session, dan lokasi token
Untuk aplikasi browser, refresh token biasanya disimpan di Cookie HttpOnly, Secure, dan SameSite. HttpOnly mencegah JavaScript membaca nilai cookie secara langsung, sehingga pencurian token saat XSS lebih sulit. Namun cookie dikirim otomatis, jadi route refresh, logout, dan perubahan state tetap butuh CSRF protection.
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 bisa disimpan di memory untuk SPA, dengan konsekuensi reload memerlukan refresh. Dengan BFF atau Next.js Route Handler, server bisa mem-proxy API call sehingga access token tidak perlu diberikan ke browser. localStorage memang mudah, tetapi XSS dapat membaca bearer token. Jangan taruh token panjang di sana.
Revocation dan key rotation
JWT tetap valid sampai exp jika server tidak menambah kontrol. Gunakan access token pendek, daftar revocation untuk jti, revocation berbasis sid, dan refresh token rotation. Logout harus revoke refresh record. Password change, account suspension, atau indikasi leak harus revoke semua session user.
Key rotation butuh masa overlap. HS256 sederhana, tetapi semua verifier memegang shared secret. Saat service bertambah, RS256 atau ES256 dengan JWKS lebih mudah dioperasikan karena verifier hanya butuh public key.
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": "Buat key baru dan publish ke JWKS",
"step2": "Sign token baru dengan kid baru",
"step3": "Pertahankan public key lama sampai token lama expired",
"step4": "Review logs lalu hapus key lama"
}
}
Use case dan prompt aman
flowchart LR
Login["Login"] --> Access["Access token pendek"]
Login --> Refresh["Refresh cookie HttpOnly"]
Access --> API["API validasi iss/aud/exp/jti"]
Refresh --> Rotate["Rotation saat refresh"]
Rotate --> Store["DB/Redis simpan hash dan sid"]
Store --> Reuse["Reuse: revoke token family"]
Use case 1 adalah dashboard SaaS. tenantId boleh masuk ke claim untuk konteks, tetapi query database tetap harus filter tenant. Hak admin, status billing, dan suspension harus dicek ulang di server untuk aksi berisiko.
Use case 2 adalah situs kursus atau konten berbayar. Access token pendek dan refresh berjalan diam-diam agar pembaca tidak terputus di tengah pelajaran. Jika site juga memakai ads, Analytics, dan purchase CTA, hubungkan dengan web security headers dan cookie consent.
Use case 3 adalah aplikasi mobile atau desktop. Biasanya memakai secure storage OS, bukan Cookie browser. Tetap simpan sid agar device hilang bisa direvoke, dan catat refresh token reuse sebagai security event.
Use case 4 adalah microservices. Jangan sebar symmetric secret ke semua service. Pertimbangkan public-key verification, gateway, atau token exchange. Setiap service wajib validasi aud.
Desain dan implementasikan autentikasi JWT di repository ini.
Sebelum edit, buat tabel:
- framework, user model, session/cookie code, auth middleware
- authorization check, CSRF, CSP, rate limit yang ada
- lokasi token sekarang dan risiko XSS/CSRF
Aturan:
- Gunakan jose. Jangan kembali ke jsonwebtoken.
- Access token 15 menit. Refresh token 7 hari.
- Validasi iss, aud, sub, exp, iat, jti, sid.
- Simpan hanya hash refresh token di DB/Redis.
- Rotate refresh token dan revoke sid family saat reuse.
- Jangan print secrets, .env, atau production token.
- Akhiri dengan bukti test atau curl.
Failure, verifikasi, dan CTA
Failure yang sering muncul: algoritma tidak di-pin, payload berisi PII atau secret, aud/iss tidak dicek, refresh token bisa dipakai ulang, logout hanya clear cookie browser, old key dihapus terlalu cepat, dan secret asli ditempel ke Claude Code. Pertahanannya adalah algorithm allowlist, minimal claims, rotation, revocation berbasis sid, overlap JWKS, dan prompt tanpa secret.
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 token, signature yang diubah, audience salah, jti direvoke, refresh token dipakai ulang, logout, password change, dan account suspension. Pastikan refresh Cookie punya HttpOnly, Secure, SameSite yang tepat, serta path yang sempit.
Developer individu bisa mulai dari cheatsheet Claude Code gratis untuk membakukan kebiasaan verifikasi. Untuk prompt dan template reusable, lihat ClaudeCodeLab products. Tim yang perlu menyatukan JWT, RBAC, cookies, audit log, dan CI gates bisa memakai Claude Code training and consultation.
Saat mencoba contoh di artikel ini, langkah yang paling mengurangi rework adalah menulis tabel claim sebelum kode signing. Dari sana terlihat tiga masalah lebih awal: aud belum dicek, contoh top-level await tidak berjalan di path tsx default, dan risiko menyimpan refresh token dalam plain text. JWT yang aman bukan sekadar sign token; ia menggabungkan claims, validasi, storage, rotation, revocation, dan key management menjadi workflow yang bisa direview.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.