JWT refresh token rotate secure pattern
Refresh token rotation: setiap refresh kasih token baru, invalidate yang lama. Mitigasi token theft di SPA dan mobile app.
Dipublikasikan 7 Juni 2026
JWT access token 15 menit aman, tapi user gak mau login tiap 15 menit. Solusinya refresh token. Tapi refresh token yang panjang umurnya itu juicy target — kalau bocor sekali, attacker bisa pegang akses selamanya. Pattern rotation di bawah bikin token sekali pakai dan auto-detect compromise.
Kode
import { randomBytes, createHash } from "node:crypto";
import { SignJWT, jwtVerify } from "jose";
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const ACCESS_TTL = 15 * 60; // 15 menit
const REFRESH_TTL_DAYS = 30;
interface RefreshTokenRow {
id: string; // hash token (jangan store plain)
family_id: string; // grouping untuk detect reuse
user_id: string;
expires_at: Date;
revoked: boolean;
used: boolean;
replaced_by?: string; // ID token pengganti
}
// In-memory store untuk demo — ganti dengan Postgres / Redis di production
const refreshStore = new Map<string, RefreshTokenRow>();
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function newRefreshToken(): string {
return randomBytes(32).toString("base64url");
}
export async function createSession(userId: string) {
const familyId = randomBytes(16).toString("hex");
return issueTokens(userId, familyId);
}
async function issueTokens(userId: string, familyId: string) {
// Access token = signed JWT
const accessToken = await new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${ACCESS_TTL}s`)
.sign(JWT_SECRET);
// Refresh token = random opaque, di-hash sebelum simpan
const refreshToken = newRefreshToken();
const refreshId = hashToken(refreshToken);
refreshStore.set(refreshId, {
id: refreshId,
family_id: familyId,
user_id: userId,
expires_at: new Date(Date.now() + REFRESH_TTL_DAYS * 86_400_000),
revoked: false,
used: false,
});
return { accessToken, refreshToken };
}
export async function rotateRefresh(refreshToken: string) {
const refreshId = hashToken(refreshToken);
const row = refreshStore.get(refreshId);
if (!row) {
throw new Error("Token tidak ditemukan");
}
if (row.revoked) {
throw new Error("Token sudah di-revoke");
}
if (row.expires_at < new Date()) {
throw new Error("Token expired");
}
// DETECTION: kalau token sudah pernah dipakai → reuse attack
if (row.used) {
revokeFamily(row.family_id);
throw new Error("Reuse detected — seluruh sesi di-revoke");
}
// Mark used + issue token baru di family yang sama
row.used = true;
const tokens = await issueTokens(row.user_id, row.family_id);
row.replaced_by = hashToken(tokens.refreshToken);
return tokens;
}
function revokeFamily(familyId: string): void {
for (const row of refreshStore.values()) {
if (row.family_id === familyId) {
row.revoked = true;
}
}
}
export async function verifyAccess(token: string): Promise<string> {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload.sub as string;
}
Pemakaian
// 1. Login — issue session pertama
const { accessToken, refreshToken } = await createSession("user-123");
// Set refresh token di HttpOnly cookie, kirim access ke client
// 2. Client expired access token → request refresh
const fresh = await rotateRefresh(refreshToken);
// Token lama otomatis ditandai "used"
// 3. Kalau attacker coba pakai refreshToken (sudah used) lagi:
try {
await rotateRefresh(refreshToken); // pakai token yang sudah expired
} catch (err) {
// "Reuse detected" — seluruh family revoked, user harus login ulang
console.error(err);
}
// Express middleware verifikasi access token
import type { Request, Response, NextFunction } from "express";
export async function authMiddleware(
req: Request & { userId?: string },
res: Response,
next: NextFunction
) {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing token" });
}
try {
req.userId = await verifyAccess(auth.slice(7));
next();
} catch {
res.status(401).json({ error: "Invalid token" });
}
}
Kapan dipakai
- SPA / mobile app yang butuh session panjang (e-commerce, dashboard).
- Internal tool perbankan / fintech — wajib audit trail token usage.
- API public yang issue token jangka panjang ke 3rd party developer.
- Aplikasi dengan compliance ISO 27001 / SOC 2.
Catatan
- Hash sebelum store — refresh token plain di DB sama saja kayak password plain. Kalau DB dump bocor, attacker punya semua session.
- HttpOnly + Secure + SameSite=Strict cookie untuk refresh token. Jangan pernah taruh refresh token di localStorage — XSS langsung curi.
- Access token short-lived — 15 menit. Kalau lebih panjang, percuma rotation.
- Family-based revoke — saat reuse detected, jangan cuma revoke 1 token. Revoke seluruh family sehingga user beneran dipaksa re-login.
- Cleanup expired — cron harian hapus row dengan
expires_at < now() - 7 harisupaya tabel tidak gendut.
Refresh rotation = bukan silver bullet. Kalau attacker punya access ke device user (malware, RAT), rotation gak ngeselin mereka — mereka tinggal nunggu rotation berikutnya. Tambahkan device fingerprinting untuk defense in depth.
# tags
jwtrefresh-tokenrotationsecurityauth
Ditulis oleh Asti Larasati · 7 Juni 2026