CSRF protection
ShipVeryFast ships a self-contained CSRF layer that runs in the Edge middleware: signed, time-limited tokens are minted by app/api/csrf-token, validated in middleware.ts for every mutating API request, and attached on the client through the useCSRF hook. This page covers how each piece works and how to wire it into your own forms and fetches.
How tokens are issued
A token is a signed, self-describing string, there is no server-side store. The endpoint at app/api/csrf-token/route.ts reads the current NextAuth session, derives a sessionId from session?.user?.id || session?.user?.email (falling back to anonymous), and calls generateCSRFToken() from libs/csrf.ts. The token payload is timestamp.randomValue.sessionPart, and a fourth segment is the SHA-256 signature of that payload concatenated with the secret:
// libs/csrf.ts
const payload = timestamp + "." + randomValue + "." + sessionPart;
const encoder = new TextEncoder();
const data = encoder.encode(payload + getCSRFSecret());
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const signature = Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return payload + "." + signature; // four dot-separated partsThe 16 random bytes come from crypto.getRandomValues(), so every token is unique even within the same millisecond. The response also returns expiresIn: 3600 (seconds) and sets Cache-Control: no-cache, no-store, must-revalidate so tokens are never cached by a proxy or the browser.
The 1-hour TOKEN_LIFETIME
Tokens expire one hour after they are minted. The lifetime is a constant in libs/csrf.ts:
const TOKEN_LIFETIME = 1000 * 60 * 60; // 1 hourDuring validation, validateCSRFToken() parses the leading timestamp and rejects the token if Date.now() - tokenTime > TOKEN_LIFETIME. The expiresIn value the API returns (3600) mirrors this hour so the client can schedule a refresh before the token goes stale.
Edge-compatible validation in libs/csrf.ts
The same module runs in both the Node and Edge runtimes, so it avoids Node-only crypto entirely. It resolves the Web Crypto API once at the top of the file and uses it for both signing and verification:
// Use Web Crypto API for Edge Runtime compatibility
const crypto = globalThis.crypto || require("crypto").webcrypto;validateCSRFToken(token, sessionId?) splits the token into its four parts (rejecting anything that is not exactly four segments), checks expiry, verifies the sessionPart matches the expected session, then recomputes the SHA-256 signature and compares it to the one carried in the token. Because the signature comparison must not leak timing information, it uses a constant-time hex comparison rather than === (covered below).
Secret resolution is lazy
The secret is read at call time, not cached at module load, via getCSRFSecret(). Reading it lazily means rotating CSRF_SECRET immediately invalidates every token signed with the previous secret, the security guarantee callers expect.
validateCSRFFromRequest in middleware
The enforcement point is middleware.ts. For any request to /api/ whose method is POST, PUT, PATCH, or DELETE (excluding the /api/csrf-token endpoint itself), the middleware runs two checks: a same-origin origin header check against env.NEXTAUTH_URL, and then validateCSRFFromRequest(req), which pulls the token from the x-csrf-token header and validates it.
// middleware.ts
if (
req.nextUrl.pathname.startsWith("/api/") &&
["POST", "PUT", "PATCH", "DELETE"].includes(req.method) &&
!req.nextUrl.pathname.startsWith("/api/csrf-token")
) {
const origin = req.headers.get("origin");
if (!origin || origin !== env.NEXTAUTH_URL) {
// ...securityLogger.log(SUSPICIOUS_ACTIVITY)...
return NextResponse.json({ error: "Invalid origin" }, { status: 403 });
}
const isValidCSRF = await validateCSRFFromRequest(req);
if (!isValidCSRF) {
const token = req.headers.get("x-csrf-token");
return NextResponse.json(
{ error: token ? "Invalid CSRF token" : "Missing CSRF token" },
{ status: 403 },
);
}
}Both a missing/foreign origin and an invalid token return 403, and each failure is recorded through securityLogger.log() as a SUSPICIOUS_ACTIVITY event, see Security monitoring for how those events surface. Note that validateCSRFFromRequest() validates without a session id (a deliberately simplified Edge path), so it confirms the signature and expiry but does not re-check the sessionPart.
The matcher does the gating
CSRF checks only run on paths the middleware matcher covers. The config.matcher in middleware.ts includes /api/:path*, so all mutating API routes are protected. If you add API routes outside that pattern, they are not covered.
The CSRF_SECRET env var
Signing keys off a single secret, CSRF_SECRET. It appears in .env.example with a placeholder that documents the expected length:
# .env.example
# CSRF Protection
CSRF_SECRET=your_csrf_secret_key_32_chars_minimumUse 32 characters or more. A quick way to generate one:
openssl rand -base64 32Not Zod-validated, set it yourself
Unlike most required env vars, CSRF_SECRET is not part of the Zod schema in libs/config.ts, so a missing value will not throw at startup. Instead, getCSRFSecret() falls back to "default-csrf-secret-change-in-production". That fallback keeps a fresh clone running, but it is public knowledge, set a real secret before you deploy.
Client usage via useCSRF
On the client, libs/useCSRF.ts exports the useCSRF hook. It fetches a token from /api/csrf-token on mount, exposes it as csrfToken, and auto-refreshes it five minutes before expiry (with a one-minute floor and a 30-second retry on error). The handiest part is getAuthHeaders(), which returns a headers object with Content-Type and the X-CSRF-Token header already populated:
"use client";
import { useCSRF } from "@/libs/useCSRF";
function UpdateProfileButton() {
const { csrfToken, isLoading, getAuthHeaders } = useCSRF();
async function save() {
const res = await fetch("/api/user/profile", {
method: "PATCH",
headers: getAuthHeaders(), // includes X-CSRF-Token
credentials: "same-origin",
body: JSON.stringify({ name: "Ada" }),
});
if (res.status === 403) {
// CSRF rejected: missing/expired token or wrong origin
}
}
return (
<button onClick={save} disabled={isLoading || !csrfToken}>
Save
</button>
);
}The hook also returns refreshToken() and an error string. If you would rather not manage hook state, the same module exports a one-shot helper, fetchWithCSRF(url, options), which fetches a fresh token and issues the request with the X-CSRF-Token header in a single call:
import { fetchWithCSRF } from "@/libs/useCSRF";
const res = await fetchWithCSRF("/api/subscription", {
method: "POST",
body: JSON.stringify({ priceId }),
});Both paths send the token in the X-CSRF-Token header, which the middleware reads case-insensitively as x-csrf-token. Because the middleware also enforces a same-origin origin header, keep credentials: "same-origin" and call your own API from the same origin as NEXTAUTH_URL.
Why timing-safe comparison matters
A naive string comparison short-circuits on the first differing character, so the time it takes to reject a token leaks how many leading bytes were correct. Given enough attempts, an attacker can recover a valid signature byte by byte. To close that side channel, libs/csrf.tscompares the supplied and expected signatures with a constant-time routine. Node's crypto.timingSafeEqual is not available in the Edge runtime, so the comparison is implemented by hand:
// libs/csrf.ts
function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}
let mismatch = 0;
for (let i = 0; i < a.length; i++) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return mismatch === 0;
}Length is compared first (lengths are not secret). Then every character is XOR-accumulated into mismatch so the loop never short-circuits, the work is identical whether the first byte or no byte matches, and the function returns true only when every XOR was zero. validateCSRFToken() calls it as the final step:
// the last line of validateCSRFToken()
return timingSafeEqualHex(signature, expectedSignature);Next steps
- Security overview, how CSRF, rate limiting, and monitoring fit together.
- Rate limiting, the other guard the same middleware applies to
/api/routes. - Security monitoring , where rejected CSRF attempts are logged.
