karawaci.kode

← Semua snippet

TypeScript Menengah Auth

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 hari supaya 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