karawaci.kode

← Semua snippet

TypeScript Menengah Auth

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 lax kalau ada use case redirect, atau none + 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 pakai sameSite: 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