← 中文资源

简体中文版本

CSRF 双提交令牌

签发与 HMAC 绑定的 CSRF 令牌,并在变更请求前校验请求头、Cookie 与会话三者一致。

阅读英文原文

功能说明

createCsrfToken 生成由随机数与 HMAC 签名组成的令牌,verifyCsrfToken 检查请求头令牌与 Cookie 令牌完全相同,并验证签名确实绑定当前会话。csrfCookie 输出适合双提交模式的 Cookie 头。该方案适合传统表单、同源前端与轻量 API,能在不依赖服务端会话存储的情况下增加跨站请求伪造的难度。

关键代码

令牌由十八字节随机数的 base64url 编码、点号与 HMAC-SHA256 签名组成。签名内容为会话标识加随机数,因此攻击者无法把其他会话的令牌移植过来。校验先比较请求头与 Cookie 的字节长度和内容,再拆出随机数重算签名。所有比较使用 timingSafeEqual,长度不等时提前返回,避免抛出异常。Cookie 使用 SameSite=Lax 和 Secure。

使用注意

双提交依赖浏览器按域发送 Cookie,若存在子域可控或 Cookie 注入风险,应使用 __Host- 前缀并严格限定域。令牌必须同时出现在请求头和 Cookie 中,且服务端不能只信任 Cookie。Cookie 需要被前端脚本读取以写入请求头,因此不能设置 HttpOnly。该方案不能替代 SameSite 与 Origin 校验,高敏感操作应叠加二次确认。

验证方式

测试正常签发后校验通过,篡改随机数、签名、请求头或 Cookie 任一字段都失败。用不同会话标识验证令牌不可跨会话复用。测试缺失字段、空字符串、无点号、长度不一致与签名长度异常均返回 false。检查 Cookie 头包含 Path、SameSite 与 Secure,并确认关闭 Secure 时仅用于本地开发。

代码示例

import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

export interface CsrfCheckInput {
  headerToken?: string;
  cookieToken?: string;
  sessionId: string;
}

export function createCsrfToken(sessionId: string, secret: string): string {
  if (!sessionId || !secret) throw new TypeError("sessionId and secret are required");
  const nonce = randomBytes(18).toString("base64url");
  const signature = createHmac("sha256", secret)
    .update(sessionId + "." + nonce)
    .digest("base64url");
  return nonce + "." + signature;
}

export function verifyCsrfToken(input: CsrfCheckInput, secret: string): boolean {
  const { headerToken, cookieToken, sessionId } = input;
  if (!headerToken || !cookieToken || !sessionId) return false;
  const header = Buffer.from(headerToken);
  const cookie = Buffer.from(cookieToken);
  if (header.length !== cookie.length || !timingSafeEqual(header, cookie)) return false;
  const separator = headerToken.lastIndexOf(".");
  if (separator <= 0) return false;
  const nonce = headerToken.slice(0, separator);
  const signature = headerToken.slice(separator + 1);
  const expected = createHmac("sha256", secret)
    .update(sessionId + "." + nonce)
    .digest("base64url");
  const provided = Buffer.from(signature);
  const expectedBuffer = Buffer.from(expected);
  return provided.length === expectedBuffer.length
    && timingSafeEqual(provided, expectedBuffer);
}

export function csrfCookie(token: string, secure = true): string {
  const flags = ["Path=/", "SameSite=Lax", secure ? "Secure" : ""].filter(Boolean);
  return "csrf_token=" + encodeURIComponent(token) + "; " + flags.join("; ");
}