PAYATHON 2026

SPA guest checkout 如何识别同一匿名用户 session

支付小周

结论

不要让 SPA 使用固定的 username/password 创建 token。更安全、更通用的方案是:

  1. 用户首次进入结账流程时,由服务端创建一个随机且不可预测的 guest session。
  2. 服务端通过带有 HttpOnly 和 Secure 属性的 Cookie,将 session 标识交给浏览器。
  3. 浏览器在后续 API 请求中自动携带该 Cookie。
  4. 每个 API 都根据 session 在服务端查找对应的 guest checkout,并检查资源归属。

这个标识可以是传统的随机 session ID,也可以是服务端签发的短期 token。对于 guest checkout,不透明的 session ID 通常更简单。浏览器只保存随机标识,姓名、地址和订单状态等数据仍保存在服务端。

推荐的数据模型

不要只创建一个笼统的“匿名用户”。结账场景更适合将 guest session 与 checkout 分开:

guest_sessions
- id
- token_hash
- created_at
- expires_at
- revoked_at

checkouts
- id
- guest_session_id
- status
- name
- address
- other_details
- expires_at

guest_sessions.token_hash 保存 session ID 的哈希值,而不是 Cookie 中的原始值。即使数据库内容泄露,攻击者也不能直接使用数据库里的值冒充该 session。

每次操作 checkout 时,都必须检查:

checkout.guest_session_id == current_session.id

客户端提交 checkoutId,并不代表它有权修改对应的 checkout。

建立 guest session

服务端可以在用户首次访问结账 API 时自动创建 session,也可以提供明确的初始化接口,例如:

POST /api/guest-sessions

下面以 Node.js 和 Express 为例。代码省略了数据库实现,但保留了主要的安全逻辑:

import crypto from "node:crypto";
import express from "express";
import cookieParser from "cookie-parser";

const app = express();

app.use(express.json());
app.use(cookieParser());

function hashToken(token) {
  return crypto
    .createHash("sha256")
    .update(token, "utf8")
    .digest("hex");
}

app.post("/api/guest-sessions", async (req, res) => {
  const sessionToken = crypto.randomBytes(32).toString("base64url");
  const tokenHash = hashToken(sessionToken);

  const session = await db.guestSessions.create({
    tokenHash,
    expiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000)
  });

  res.cookie("guest_session", sessionToken, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/api",
    maxAge: 2 * 60 * 60 * 1000
  });

  res.status(201).json({
    sessionExpiresAt: session.expiresAt
  });
});

服务端使用密码学安全的随机数生成 session ID,不需要用户名和密码。HttpOnly 可阻止前端 JavaScript 读取 Cookie,降低 token 被 XSS 直接窃取的风险。

生产环境只能通过 HTTPS 使用 Secure Cookie。

在不同 API 中识别同一个 guest

可以编写统一的认证中间件,从 Cookie 读取 session ID,再到服务端存储中查询:

async function requireGuestSession(req, res, next) {
  const sessionToken = req.cookies.guest_session;

  if (!sessionToken) {
    return res.status(401).json({
      error: "Guest session is missing"
    });
  }

  const session = await db.guestSessions.findByTokenHash(
    hashToken(sessionToken)
  );

  if (
    !session ||
    session.revokedAt ||
    session.expiresAt.getTime() <= Date.now()
  ) {
    return res.status(401).json({
      error: "Guest session is invalid or expired"
    });
  }

  req.guestSession = session;
  next();
}

创建 checkout 时,归属关系由服务端写入,不接受客户端指定的 guestSessionId:

app.post(
  "/api/checkouts",
  requireGuestSession,
  async (req, res) => {
    const checkout = await db.checkouts.create({
      guestSessionId: req.guestSession.id,
      name: req.body.name,
      address: req.body.address,
      status: "pending"
    });

    res.status(201).json({
      checkoutId: checkout.id
    });
  }
);

更新其他结账信息时,查询条件必须同时包含 checkout ID 和当前 session:

app.patch(
  "/api/checkouts/:checkoutId/details",
  requireGuestSession,
  async (req, res) => {
    const checkout = await db.checkouts.findOne({
      id: req.params.checkoutId,
      guestSessionId: req.guestSession.id
    });

    if (!checkout) {
      return res.status(404).json({
        error: "Checkout not found"
      });
    }

    const updatedCheckout = await db.checkouts.update(checkout.id, {
      otherDetails: req.body.otherDetails
    });

    res.json({
      checkoutId: updatedCheckout.id,
      status: updatedCheckout.status
    });
  }
);

这样,即使有人猜到或获得了另一个 checkout 的 ID,也无法通过自己的 guest session 修改它。

SPA 如何发送请求

如果 SPA 和 API 位于同一个站点,浏览器通常会自动携带匹配的 Cookie。也可以显式设置:

await fetch("/api/checkouts/123/details", {
  method: "PATCH",
  credentials: "same-origin",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    otherDetails: {
      deliveryNote: "Leave at reception"
    }
  })
});

如果 SPA 与 API 跨域,例如:

https://shop.example.com
https://api.example.net

客户端需要使用:

await fetch("https://api.example.net/api/checkouts/123/details", {
  method: "PATCH",
  credentials: "include",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    otherDetails: {
      deliveryNote: "Leave at reception"
    }
  })
});

服务端也必须返回精确的 CORS 来源,并允许携带凭据:

Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Credentials: true

请求携带凭据时,不能使用:

Access-Control-Allow-Origin: *

跨站 Cookie 通常还需要以下设置:

{
  httpOnly: true,
  secure: true,
  sameSite: "none"
}

不过,跨站 Cookie 会受到浏览器隐私策略的影响。条件允许时,最好将 SPA 和 API 部署在同一站点下,或通过反向代理把 API 暴露为 /api。

HttpOnly 能阻止 JavaScript 读取 Cookie,但浏览器仍可能在跨站请求中自动发送 Cookie,因此还要防范 CSRF。

可以组合使用以下措施:

  • 优先设置 SameSite=Lax,业务允许时也可使用 SameSite=Strict。
  • 校验 Origin,必要时同时校验 Referer。
  • 为创建、修改和提交订单等请求配置 CSRF token。
  • 不使用 GET 请求修改服务器状态。
  • CORS 只允许明确、可信的来源。

CSRF token 不是身份凭证。页面可以读取它并将其放入请求头,而真正的 guest session Cookie 仍保持 HttpOnly。

例如:

await fetch("/api/checkouts/123/submit", {
  method: "POST",
  credentials: "same-origin",
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-Token": csrfToken
  },
  body: JSON.stringify({})
});

是否必须使用 JWT

不必须。JWT 只是 token 的一种格式,本身不能解决 guest 身份识别问题。

对于 guest checkout,不透明的 session ID 往往更合适:

  • 可以立即撤销。
  • 服务端容易控制过期时间。
  • Cookie 不会暴露业务数据。
  • guest 状态本来就需要保存在数据库或缓存中。
  • 无需额外处理 JWT 的状态更新、吊销和密钥轮换。

如果系统已有成熟的身份服务,也可以让该服务签发短期 anonymous access token。不过,这类 token 仍须由可信的服务端签发,并包含随机身份、明确的 audience、较短的过期时间和受限权限。不要在 SPA 中保存固定的 client secret,因为公开 SPA 无法可靠地保守 secret。

其他注意事项

  • session ID 应具有足够的随机强度,例如使用 crypto.randomBytes(32),不要使用自增 ID、时间戳或普通随机函数。
  • guest session 和未完成的 checkout 都应设置过期时间,并由后台任务清理。
  • 支付成功、订单取消或超时后,应撤销 session,或禁止继续修改 checkout。
  • 不要把姓名、地址、金额或权限信息直接写入可由客户端修改的 Cookie。
  • 金额、折扣、库存、运费和订单最终状态都必须由服务端计算或验证。
  • 为创建 checkout、应用优惠和提交订单等接口增加限流和幂等控制。
  • 防止 session fixation。服务端必须创建新的随机 session,不能直接采用客户端提供的 session ID。
  • XSS 仍可能借助用户的浏览器发起请求,因此需要正确转义输出,并配置合适的 Content Security Policy。
  • 如果需要将 guest 升级为注册用户,应在登录成功后重新生成 session,再由服务端安全地迁移或绑定 checkout。

客户端可以持有服务端颁发的随机标识,但不能决定该标识代表谁、拥有哪些资源或具有什么权限。所有 session 验证和 checkout 归属判断都必须在服务端完成。

备注:内容仅供参考。