关于 headless-bff-architecture
面向构建无头 VTEX 商店的开发者,提供 BFF 架构与安全设计指导,覆盖 API 代理、凭证管理和会话令牌处理。技能要求通过服务端 BFF 处理 VTEX API 调用,同时允许前端直接访问 Intelligent Search。
主要功能
- 01为无头商店设置服务端 BFF
- 02区分需代理与可直连的 VTEX API
- 03在服务端管理凭证和会话令牌
- 04按模块使用最小权限 API 密钥
- 05检查请求验证与敏感日志处理
适用场景
- 01为无头商店设计 API 请求路径
- 02防止 VTEX 密钥进入浏览器代码
- 03安全管理购物者身份令牌和会话
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ vtex/ skills --skill headless-bff-architecture安装后在 Claude Code 中输入/headless-bff-architecture确认已加载。
详细介绍
BFF 层设计与安全
适用场景
在构建或修改任何与 VTEX API 通信的无头 VTEX 店面时使用此技能,无论是自定义店面、移动应用还是自助服务终端。
- 为新的无头项目搭建 BFF(前端后端)层
- 确定哪些 VTEX API 需要在服务器端代理,哪些可以由前端直接调用
- 实现凭据管理(
VTEX_APP_KEY、VTEX_APP_TOKEN、VtexIdclientAutCookie) - 审查无头架构是否符合安全要求
请勿将此技能用于:
- 特定于结账的代理逻辑和 OrderForm 管理(使用
headless-checkout-proxy) - Search API 集成详情(使用
headless-intelligent-search) - 缓存和 TTL 策略(使用
headless-caching-strategy)
决策规则
- 所有无头 VTEX 项目都必须使用 BFF 层。无头店面不存在可以在没有 BFF 的情况下安全运行的场景。
- 所有 VTEX API 调用都必须通过 BFF 路由,唯一例外是 Intelligent Search,它是唯一可以安全地由前端直接调用的 API。
- 对于按购物者范围调用的 API,使用存储在服务器端的
VtexIdclientAutCookie。对于机器对机器调用,使用X-VTEX-API-AppKey/X-VTEX-API-AppToken。 - 将客户端暴露
VTEX_APP_KEY、VTEX_APP_TOKEN、VtexIdclientAutCookie、结账 Cookie 或购物者/会话令牌视为安全违规,而不是建议或权衡。请使用“不得”“绝不暴露”和“仅限服务器端”等明确表述,避免使用“避免”“优先考虑”或“最好”等较弱的措辞。 - 根据 API 路径分类:
/pub/端点是公开的,但大多数仍需要通过 BFF 代理以管理会话;/pvt/端点是私有的,必须通过 BFF 调用。 - 即使是公开的 Checkout 端点(
/api/checkout/pub/),也必须通过 BFF 代理以确保安全,因为这些端点会处理敏感个人数据。 - 为不同的 BFF 模块创建权限最小化的独立 API 密钥,不要共用一个权限宽泛的密钥。
严格约束
约束:无头 VTEX 必须使用 BFF 层,不得例外
每个无头 VTEX 店面都必须使用服务器端 BFF 层。客户端代码不得直接向 VTEX 私有 API 端点发送 HTTP 请求。所有私有 API 调用都必须通过 BFF 路由。
原因
VTEX 私有 API 需要 X-VTEX-API-AppKey 和 X-VTEX-API-AppToken 请求头。如果前端直接调用这些 API,凭据就必须嵌入客户端代码或传输到浏览器,任何打开浏览器开发者工具的用户都能获取这些凭据。被盗的 API 密钥可用于访问订单数据、修改价格或执行具有破坏性的管理操作。
检测
如果在客户端代码(src/、public/、app/ 下的文件,或任何在浏览器中执行的代码包)中发现对 vtexcommercestable.com.br/api/checkout、/api/oms、/api/profile 或任何 /pvt/ 端点的 fetch 或 axios 调用 → 立即停止。这些调用必须移至 BFF。
正确
// Frontend code — calls BFF, not VTEX directly
async function getOrderDetails(orderId: string): Promise<Order> {
const response = await fetch(`/api/bff/orders/${orderId}`, {
credentials: "include", // sends session cookie to BFF
});
if (!response.ok) {
throw new Error(`Failed to fetch order: ${response.status}`);
}
return response.json();
}
错误
// Frontend code — calls VTEX OMS API directly (SECURITY VULNERABILITY)
async function getOrderDetails(orderId: string): Promise<Order> {
const response = await fetch(
`https://mystore.vtexcommercestable.com.br/api/oms/pvt/orders/${orderId}`,
{
headers: {
"X-VTEX-API-AppKey": "vtexappkey-mystore-ABCDEF", // EXPOSED!
"X-VTEX-API-AppToken": "eyJhbGciOi...", // EXPOSED!
},
}
);
return response.json();
}
约束:必须在服务端管理 VtexIdclientAutCookie
必须将 VtexIdclientAutCookie 令牌存储在安全的服务端会话中(例如加密 Cookie、Redis 会话存储),不得存储在 localStorage、sessionStorage 或任何客户端可访问的 JavaScript 变量中。
为何这很重要
VtexIdclientAutCookie 是一个持有者令牌,可代表购物者验证所有操作,包括下单、查看个人资料数据和访问支付信息。如果将其存储在客户端,令牌可能通过 XSS 攻击、浏览器扩展程序或共享/公用计算机被窃取。持有此令牌的攻击者可以冒充购物者。
检测
如果发现客户端代码中的 VtexIdclientAutCookie 被用于 localStorage.setItem、sessionStorage.setItem,或被赋值给 JavaScript 变量 → 立即停止。此令牌必须完全由服务端管理。
正确
// BFF route — stores VtexIdclientAutCookie in server-side session
import { Router, Request, Response } from "express";
import session from "express-session";
const router = Router();
// After VTEX login callback, extract and store token server-side
router.get("/auth/callback", async (req: Request, res: Response) => {
const vtexAuthToken = req.cookies["VtexIdclientAutCookie"];
if (!vtexAuthToken) {
return res.status(401).json({ error: "Authentication failed" });
}
// Store in server-side session — never sent to frontend
req.session.vtexAuthToken = vtexAuthToken;
// Clear the cookie from the browser response
res.clearCookie("VtexIdclientAutCookie");
// Redirect to frontend with a secure session cookie
res.redirect("/account");
});
// BFF proxy uses session token for VTEX API calls
router.get("/api/bff/profile", async (req: Request, res: Response) => {
const vtexToken = req.session.vtexAuthToken;
if (!vtexToken) {
return res.status(401).json({ error: "Not authenticated" });
}
const response = await fetch(
`https://${process.env.VTEX_ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/profiles`,
{
headers: {
Cookie: `VtexIdclientAutCookie=${vtexToken}`,
},
}
);
const profile = await response.json();
res.json(profile);
});
错误
// Frontend code — stores auth token in localStorage (SECURITY VULNERABILITY)
function handleLoginCallback() {
const params = new URLSearchParams(window.location.search);
const vtexToken = params.get("authToken");
// WRONG: token is now accessible to any JS on the page, including XSS payloads
localStorage.setItem("VtexIdclientAutCookie", vtexToken!);
}
// Later, reads from localStorage and sends in header
async function getProfile() {
const token = localStorage.getItem("VtexIdclientAutCookie"); // EXPOSED!
return fetch("https://mystore.vtexcommercestable.com.br/api/checkout/pub/profiles", {
headers: { Cookie: `VtexIdclientAutCookie=${token}` },
});
}
约束:API 密钥不得出现在客户端代码中
VTEX_APP_KEY 和 VTEX_APP_TOKEN 的值只能存在于服务端环境变量中,不得出现在任何会被打包、提供或供浏览器访问的文件中。
为何这很重要
API 密钥可授予对 VTEX 平台的程序化访问权限,权限范围取决于关联角色。将其暴露在前端包、公共目录或客户端环境变量(例如 NEXT_PUBLIC_*、VITE_*)中,会使任何人都能提取密钥并发起未经授权的 API 调用。
检测
如果在 src/、public/、app/ 目录中的文件,或以 NEXT_PUBLIC_、VITE_ 或 REACT_APP_ 为前缀的环境变量中发现 VTEX_APP_KEY、VTEX_APP_TOKEN、X-VTEX-API-AppKey 或 X-VTEX-API-AppToken → 立即停止。请将它们移至仅供服务端使用的环境变量中。
正确
// BFF server code — reads keys from server-side env vars only
// File: server/vtex-client.ts (never bundled for browser)
import { Router, Request, Response } from "express";
const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_APP_KEY = process.env.VTEX_APP_KEY!;
const VTEX_APP_TOKEN = process.env.VTEX_APP_TOKEN!;
const router = Router();
router.get("/api/bff/orders/:orderId", async (req: Request, res: Response) => {
const { orderId } = req.params;
const response = await fetch(
`https://${VTEX_ACCOUNT}.vtexcommercestable.com.br/api/oms/pvt/orders/${orderId}`,
{
headers: {
"X-VTEX-API-AppKey": VTEX_APP_KEY,
"X-VTEX-API-AppToken": VTEX_APP_TOKEN,
Accept: "application/json",
},
}
);
if (!response.ok) {
return res.status(response.status).json({ error: "Failed to fetch order" });
}
const order = await response.json();
res.json(order);
});
export default router;
错误
// .env file with NEXT_PUBLIC_ prefix — exposed to browser bundle!
// NEXT_PUBLIC_VTEX_APP_KEY=vtexappkey-mystore-ABCDEF
// NEXT_PUBLIC_VTEX_APP_TOKEN=eyJhbGciOi...
// Frontend code reads exposed env vars
async function fetchOrders() {
const response = await fetch(
`https://mystore.vtexcommercestable.com.br/api/oms/pvt/orders`,
{
headers: {
"X-VTEX-API-AppKey": process.env.NEXT_PUBLIC_VTEX_APP_KEY!, // EXPOSED IN BUNDLE!
"X-VTEX-API-AppToken": process.env.NEXT_PUBLIC_VTEX_APP_TOKEN!, // EXPOSED IN BUNDLE!
},
}
);
return response.json();
}
首选模式
架构概览 — 请求如何通过 BFF 流转:
Frontend (Browser/App)
│
├── Direct call (OK): Intelligent Search API (public, read-only)
│
└── All other requests → BFF Layer (Node.js/Express)
│
├── Injects VtexIdclientAutCookie from session
├── Injects X-VTEX-API-AppKey / X-VTEX-API-AppToken
├── Validates & sanitizes input
└── Proxies to VTEX APIs
│
├── Checkout API (/api/checkout/pub/...)
├── OMS API (/api/oms/pvt/...)
├── Profile API (/api/profile-system/pvt/...)
└── Other VTEX services
包含会话管理的最简 BFF 服务端配置:
// server/index.ts
import express from "express";
import session from "express-session";
import cookieParser from "cookie-parser";
import cors from "cors";
import { checkoutRoutes } from "./routes/checkout";
import { profileRoutes } from "./routes/profile";
import { ordersRoutes } from "./routes/orders";
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
cors({
origin: process.env.FRONTEND_URL,
credentials: true,
})
);
app.use(
session({
// Production BFFs are typically multi-replica. Configure a shared session
// store (e.g. connect-redis, connect-memcached, a Postgres-backed store)
// here — the default MemoryStore is per-pod and silently drops values such
// as orderFormId and vtexAuthToken when requests are routed to a different
// replica. For ephemeral cross-step values that the browser already sees
// (e.g. orderGroup between Step 1 and Step 3 of order placement), prefer
// passing them through the request body instead of writing them to the
// session — see headless-checkout-proxy.
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "strict",
maxAge: 24 * 60 * 60 * 1000, // 24 hours (matches VtexIdclientAutCookie TTL)
},
})
);
// Mount BFF routes
app.use("/api/bff/checkout", checkoutRoutes);
app.use("/api/bff/profile", profileRoutes);
app.use("/api/bff/orders", ordersRoutes);
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`BFF server running on port ${PORT}`);
});
为两种身份验证类型注入凭据的 VTEX API 客户端:
// server/vtex-api-client.ts
const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_ENVIRONMENT = process.env.VTEX_ENVIRONMENT || "vtexcommercestable";
const VTEX_APP_KEY = process.env.VTEX_APP_KEY!;
const VTEX_APP_TOKEN = process.env.VTEX_APP_TOKEN!;
const BASE_URL = `https://${VTEX_ACCOUNT}.${VTEX_ENVIRONMENT}.com.br`;
interface VtexRequestOptions {
path: string;
method?: string;
body?: unknown;
authType: "app-key" | "user-token";
userToken?: string;
}
export async function vtexRequest<T>(options: VtexRequestOptions): Promise<T> {
const { path, method = "GET", body, authType, userToken } = options;
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (authType === "app-key") {
headers["X-VTEX-API-AppKey"] = VTEX_APP_KEY;
headers["X-VTEX-API-AppToken"] = VTEX_APP_TOKEN;
} else if (authType === "user-token" && userToken) {
headers["Cookie"] = `VtexIdclientAutCookie=${userToken}`;
}
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new Error(
`VTEX API error: ${response.status} ${response.statusText} for ${method} ${path}`
);
}
return response.json() as Promise<T>;
}
基于会话身份验证并验证输入的 BFF 路由处理程序:
// server/routes/orders.ts
import { Router, Request, Response } from "express";
import { vtexRequest } from "../vtex-api-client";
export const ordersRoutes = Router();
// Get order details — requires API key auth (private endpoint)
ordersRoutes.get("/:orderId", async (req: Request, res: Response) => {
try {
const { orderId } = req.params;
// Validate input
if (!/^[a-zA-Z0-9-]+$/.test(orderId)) {
return res.status(400).json({ error: "Invalid order ID format" });
}
// Optionally check user session for authorization
const vtexToken = req.session.vtexAuthToken;
if (!vtexToken) {
return res.status(401).json({ error: "Authentication required" });
}
const order = await vtexRequest({
path: `/api/oms/pvt/orders/${orderId}`,
authType: "app-key",
});
res.json(order);
} catch (error) {
console.error("Error fetching order:", error);
res.status(500).json({ error: "Failed to fetch order" });
}
});
使用服务端令牌管理的身份验证流程:
// server/routes/auth.ts
import { Router, Request, Response } from "express";
export const authRoutes = Router();
const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_LOGIN_URL = `https://${VTEX_ACCOUNT}.myvtex.com/login`;
const FRONTEND_URL = process.env.FRONTEND_URL!;
// Redirect shopper to VTEX login page
authRoutes.get("/login", (_req: Request, res: Response) => {
const returnUrl = `${FRONTEND_URL}/auth/callback`;
res.redirect(`${VTEX_LOGIN_URL}?returnUrl=${encodeURIComponent(returnUrl)}`);
});
// Handle login callback — extract VtexIdclientAutCookie and store server-side
authRoutes.get("/callback", (req: Request, res: Response) => {
const vtexToken = req.cookies["VtexIdclientAutCookie"];
if (!vtexToken) {
return res.redirect(`${FRONTEND_URL}/login?error=auth_failed`);
}
// Store token in server-side session
req.session.vtexAuthToken = vtexToken;
// Clear the VTEX cookie from the browser
res.clearCookie("VtexIdclientAutCookie");
// Redirect to authenticated frontend page
res.redirect(`${FRONTEND_URL}/account`);
});
// Logout — destroy session
authRoutes.post("/logout", (req: Request, res: Response) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: "Logout failed" });
}
res.clearCookie("connect.sid");
res.json({ success: true });
});
});
// Check authentication status
authRoutes.get("/status", (req: Request, res: Response) => {
res.json({
authenticated: !!req.session.vtexAuthToken,
});
});
常见故障模式
-
通过 BFF 代理 Intelligent Search:通过 BFF 路由每个 VTEX API 调用(包括 Intelligent Search)会增加不必要的延迟和服务器负载。Intelligent Search 是面向公众的只读 API,专为前端直接调用而设计。请从前端直接调用。
// Frontend code — call Intelligent Search directly (this is correct!) async function searchProducts(query: string, from: number = 0, to: number = 19): Promise<SearchResult> { const baseUrl = `https://${STORE_ACCOUNT}.vtexcommercestable.com.br`; const response = await fetch( `${baseUrl}/api/io/_v/api/intelligent-search/product_search/?query=${encodeURIComponent(query)}&from=${from}&to=${to}&locale=en-US`, ); return response.json(); } -
在所有 BFF 操作中共用一个 API 密钥:使用一个权限广泛(例如 Owner 角色)的 API 密钥执行所有 BFF 操作,意味着密钥一旦泄露,攻击者就能访问所有 VTEX 资源。请为不同的 BFF 模块创建单独的 API 密钥,并仅授予所需的最低权限。
// server/vtex-credentials.ts — separate keys per domain export const credentials = { oms: { appKey: process.env.VTEX_OMS_APP_KEY!, appToken: process.env.VTEX_OMS_APP_TOKEN!, }, checkout: { appKey: process.env.VTEX_CHECKOUT_APP_KEY!, appToken: process.env.VTEX_CHECKOUT_APP_TOKEN!, }, catalog: { appKey: process.env.VTEX_CATALOG_APP_KEY!, appToken: process.env.VTEX_CATALOG_APP_TOKEN!, }, } as const; -
记录 API 凭据或身份验证令牌:调试时记录请求头或完整请求对象,可能会无意中将 API 密钥或
VtexIdclientAutCookie值写入日志文件,而这些文件可能被多个团队成员或攻击者访问。记录日志前,请清理所有日志输出,移除敏感请求头。// server/middleware/request-logger.ts import { Request, Response, NextFunction } from "express"; const SENSITIVE_HEADERS = [ "x-vtex-api-appkey", "x-vtex-api-apptoken", "cookie", "authorization", ]; export function requestLogger(req: Request, _res: Response, next: NextFunction) { const sanitizedHeaders = Object.fromEntries( Object.entries(req.headers).map(([key, value]) => SENSITIVE_HEADERS.includes(key.toLowerCase()) ? [key, "[REDACTED]"] : [key, value] ) ); console.log({ method: req.method, path: req.path, headers: sanitizedHeaders, timestamp: new Date().toISOString(), }); next(); }
审查清单
- 是否存在 BFF 层?所有无头 VTEX 项目都必须具备此层,无一例外。
- 所有私有 VTEX API 调用(
/pvt/端点)是否都通过 BFF 路由? -
VTEX_APP_KEY和VTEX_APP_TOKEN是否仅存储在服务端环境变量中? -
NEXT_PUBLIC_*、VITE_*或REACT_APP_*环境变量中是否都没有 API 密钥? -
VtexIdclientAutCookie是否存储在服务端会话中,而非localStorage或sessionStorage中? - Intelligent Search 是否由前端直接调用(而非不必要地通过 BFF 代理)?
- 不同的 BFF 模块是否使用了权限最小化的独立 API 密钥?
- 是否已从所有日志输出中移除敏感请求头?
参考资料
- 无头商务概述 — 在 VTEX 上构建无头商店的核心架构指南
- 无头身份验证 — 无头实现中基于 OAuth 的购物者身份验证流程
- 使用 API 密钥进行 API 身份验证 — 如何使用 appKey/appToken 对进行机器身份验证
- 使用用户令牌进行 API 身份验证 — VtexIdclientAutCookie 的工作方式及其作用域
- 无头实现的刷新令牌流程 — 如何刷新过期的 VtexIdclientAutCookie 令牌
- 使用应用密钥的最佳实践 — VTEX 关于 API 密钥管理的安全指南