CSRF double-submit cookie pattern
Proteksi CSRF tanpa server state — token di cookie + header, server verify match. Cocok untuk JWT-based session di SPA.
Dipublikasikan 1 Juli 2026
Kalau pakai JWT atau token-based auth, traditional CSRF token (server-side session) tidak relevan. Double-submit cookie pattern: server set token di cookie, client baca cookie itu dan kirim balik di header. Attacker tidak bisa baca cookie cross-origin, jadi tidak bisa craft request valid.
Kode
// csrf.ts
import { randomBytes, timingSafeEqual } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
const CSRF_COOKIE = "csrf_token";
const CSRF_HEADER = "x-csrf-token";
function generateToken(): string {
return randomBytes(32).toString("base64url");
}
function safeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
try {
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
} catch {
return false;
}
}
interface CsrfOptions {
ignoreMethods?: string[];
ignorePaths?: RegExp[];
cookieOptions?: {
domain?: string;
secure?: boolean;
sameSite?: "strict" | "lax" | "none";
};
}
export function setupCsrf(app: FastifyInstance, opts: CsrfOptions = {}) {
const ignoreMethods = new Set(opts.ignoreMethods ?? ["GET", "HEAD", "OPTIONS"]);
const ignorePaths = opts.ignorePaths ?? [];
const cookieOpts = opts.cookieOptions ?? {};
// Hook 1: set CSRF token kalau belum ada (di response GET)
app.addHook("onSend", async (req, reply, payload) => {
const existing = (req.cookies?.[CSRF_COOKIE] as string | undefined) ?? "";
if (!existing) {
const token = generateToken();
reply.setCookie(CSRF_COOKIE, token, {
path: "/",
httpOnly: false, // FALSE — JS harus bisa baca
secure: cookieOpts.secure ?? process.env.NODE_ENV === "production",
sameSite: cookieOpts.sameSite ?? "strict",
domain: cookieOpts.domain,
maxAge: 60 * 60 * 24, // 24 jam
});
}
return payload;
});
// Hook 2: verify CSRF token di state-changing request
app.addHook("preHandler", async (req: FastifyRequest, reply: FastifyReply) => {
if (ignoreMethods.has(req.method.toUpperCase())) return;
const path = req.url.split("?")[0];
if (ignorePaths.some((re) => re.test(path))) return;
const cookieToken = (req.cookies?.[CSRF_COOKIE] as string | undefined) ?? "";
const headerToken = (req.headers[CSRF_HEADER] as string | undefined) ?? "";
if (!cookieToken || !headerToken) {
reply.code(403).send({ error: "CSRF token tidak ada" });
return reply;
}
if (!safeCompare(cookieToken, headerToken)) {
reply.code(403).send({ error: "CSRF token tidak match" });
return reply;
}
});
}
// server.ts
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import { setupCsrf } from "./csrf";
const app = Fastify({ logger: true });
await app.register(cookie);
setupCsrf(app, {
ignorePaths: [/^\/api\/webhook/], // webhook dari payment gateway tidak perlu CSRF
cookieOptions: {
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
},
});
app.get("/", async () => {
return { msg: "CSRF cookie sudah di-set, cek browser" };
});
app.post("/api/order", async (req) => {
const { produk_id, qty } = req.body as { produk_id: number; qty: number };
// ... proses order
return { ok: true, produk_id, qty };
});
await app.listen({ port: 3000, host: "0.0.0.0" });
Pemakaian
// client.ts — helper untuk include CSRF token otomatis
function getCsrfToken(): string {
const match = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return match ? decodeURIComponent(match[1]) : "";
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(path, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": getCsrfToken(),
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
// Pemakaian
async function buatOrder(produkId: number, qty: number) {
return apiPost("/api/order", { produk_id: produkId, qty });
}
// axios interceptor — automatic include CSRF di setiap state-changing call
import axios from "axios";
axios.interceptors.request.use((config) => {
if (["post", "put", "patch", "delete"].includes(config.method ?? "")) {
const token = document.cookie
.split("; ")
.find((row) => row.startsWith("csrf_token="))
?.split("=")[1];
if (token) {
config.headers["X-CSRF-Token"] = decodeURIComponent(token);
}
}
config.withCredentials = true;
return config;
});
Kapan dipakai
- SPA + JWT auth — no server session untuk pakai sync token pattern.
- API yang serve browser + mobile — token tidak perlu di mobile (mobile pakai bearer header beda).
- Forms tradisional di legacy app yang masih cookie-based session.
- Defense-in-depth — combine dengan SameSite cookie + Origin/Referer check.
Catatan
- httpOnly false untuk CSRF token — sengaja, supaya JS bisa baca. Compare dengan session cookie yang HARUS httpOnly.
- timingSafeEqual wajib untuk compare token — string equality bisa leak via timing attack.
- SameSite=strict prevent CSRF di browser modern, tapi compatibility issue untuk redirect dari payment gateway. Pakai
laxkalau ada use case redirect, ataunone+ CSRF token full. - Ignore webhook path — webhook dari payment gateway tidak punya cookie. Verifikasi pakai signature HMAC, bukan CSRF.
- Origin header check sebagai layer tambahan:
req.headers.origin === expected_origin. Penting kalau pakaisameSite: none. - Refresh token rotation — CSRF token bisa rotate setiap login. Stale token dari tab lama bikin user re-fetch.
CSRF tidak relevan untuk API yang authenticate via Authorization header (Bearer token di header). Cross-origin request gak otomatis include header non-cookie. Pakai CSRF cuma untuk cookie-based auth.
# tags
csrfsecuritycookieauthspa
Ditulis oleh Asti Larasati · 1 Juli 2026