headless-bff-architecture
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseBFF Layer Design & Security
BFF层设计与安全
When this skill applies
本技能的适用场景
Use this skill when building or modifying any headless VTEX storefront that communicates with VTEX APIs — whether a custom storefront, mobile app, or kiosk.
- Setting up a BFF (Backend-for-Frontend) layer for a new headless project
- Deciding which VTEX APIs need server-side proxying vs direct frontend calls
- Implementing credential management (,
VTEX_APP_KEY,VTEX_APP_TOKEN)VtexIdclientAutCookie - Reviewing a headless architecture for security compliance
Do not use this skill for:
- Checkout-specific proxy logic and OrderForm management (use )
headless-checkout-proxy - Search API integration details (use )
headless-intelligent-search - Caching and TTL strategy (use )
headless-caching-strategy
当你构建或修改任何与VTEX API通信的无头VTEX店面时(包括自定义店面、移动应用或自助终端),可使用本技能。
- 为新的无头项目搭建BFF(Backend-for-Frontend)层
- 确定哪些VTEX API需要通过服务器端代理,哪些可直接由前端调用
- 实现凭证管理(、
VTEX_APP_KEY、VTEX_APP_TOKEN)VtexIdclientAutCookie - 审查无头架构的安全合规性
以下场景请勿使用本技能:
- 结账专属的代理逻辑与OrderForm管理(请使用 )
headless-checkout-proxy - 搜索API集成细节(请使用 )
headless-intelligent-search - 缓存与TTL策略(请使用 )
headless-caching-strategy
Decision rules
决策规则
- A BFF layer is mandatory for every headless VTEX project. There is no scenario where a headless storefront can safely operate without one.
- Route all VTEX API calls through the BFF except Intelligent Search, which is the only API safe to call directly from the frontend.
- Use (stored server-side) for shopper-scoped API calls. Use
VtexIdclientAutCookie/X-VTEX-API-AppKeyfor machine-to-machine calls.X-VTEX-API-AppToken - Classify APIs by their path: endpoints are public but most still need BFF proxying for session management;
/pub/endpoints are private and must go through BFF./pvt/ - Even public Checkout endpoints () must be proxied through BFF for security — they handle sensitive personal data.
/api/checkout/pub/ - Create separate API keys with minimal permissions for different BFF modules rather than sharing one key with broad access.
- 每个无头VTEX项目必须配备BFF层,不存在无需BFF层即可安全运行的无头店面场景。
- 除Intelligent Search外,所有VTEX API调用都必须通过BFF路由——Intelligent Search是唯一可安全由前端直接调用的API。
- 针对购物者范围的API调用,使用存储在服务器端的;针对机器对机器的调用,使用
VtexIdclientAutCookie/X-VTEX-API-AppKey。X-VTEX-API-AppToken - 按路径对API进行分类:端点为公开API,但多数仍需通过BFF代理以实现会话管理;
/pub/端点为私有API,必须通过BFF路由。/pvt/ - 即使是公开的结账端点()也必须通过BFF代理,因为它们处理敏感的个人数据。
/api/checkout/pub/ - 为不同的BFF模块创建具有最小权限的独立API密钥,而非使用单个权限宽泛的密钥。
Hard constraints
硬性约束
Constraint: A BFF layer is mandatory for headless VTEX — no exceptions
约束:无头VTEX必须配备BFF层——无例外
Every headless VTEX storefront MUST have a server-side BFF layer. Client-side code MUST NOT make direct HTTP requests to private VTEX API endpoints. All private API calls must be routed through the BFF.
Why this matters
Private VTEX APIs require and headers. If the frontend calls these APIs directly, the credentials must be embedded in client-side code or transmitted to the browser, exposing them to any user who opens browser DevTools. Stolen API keys can be used to access order data, modify pricing, or perform destructive administrative actions.
X-VTEX-API-AppKeyX-VTEX-API-AppTokenDetection
If you see or calls to , , , or any endpoint in client-side code (files under , , , or any browser-executed bundle) → STOP immediately. These calls must be moved to the BFF.
fetchaxiosvtexcommercestable.com.br/api/checkout/api/oms/api/profile/pvt/src/public/app/Correct
typescript
// 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();
}Wrong
typescript
// 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();
}每个无头VTEX店面必须拥有服务器端BFF层。客户端代码禁止直接向私有VTEX API端点发起HTTP请求,所有私有API调用都必须通过BFF路由。
重要性
私有VTEX API需要和请求头。如果前端直接调用这些API,凭证必须嵌入客户端代码或传输至浏览器,任何打开浏览器开发者工具的用户都能获取到这些凭证。被盗的API密钥可用于访问订单数据、修改定价或执行破坏性的管理操作。
X-VTEX-API-AppKeyX-VTEX-API-AppToken检测方式
如果你在客户端代码(、、目录下的文件,或任何浏览器可执行的打包文件)中看到或调用、、或任何端点→立即停止。这些调用必须迁移至BFF层。
src/public/app/fetchaxiosvtexcommercestable.com.br/api/checkout/api/oms/api/profile/pvt/正确示例
typescript
// 前端代码——调用BFF,而非直接调用VTEX
async function getOrderDetails(orderId: string): Promise<Order> {
const response = await fetch(`/api/bff/orders/${orderId}`, {
credentials: "include", // 向BFF发送会话Cookie
});
if (!response.ok) {
throw new Error(`获取订单失败:${response.status}`);
}
return response.json();
}错误示例
typescript
// 前端代码——直接调用VTEX OMS API(安全漏洞)
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", // 已暴露!
"X-VTEX-API-AppToken": "eyJhbGciOi...", // 已暴露!
},
}
);
return response.json();
}Constraint: VtexIdclientAutCookie MUST be managed server-side
约束:VtexIdclientAutCookie必须在服务器端管理
The token MUST be stored in a secure server-side session (e.g., encrypted cookie, Redis session store) and MUST NOT be stored in , , or any client-accessible JavaScript variable.
VtexIdclientAutCookielocalStoragesessionStorageWhy this matters
The is a bearer token that authenticates all actions on behalf of a shopper — placing orders, viewing profile data, accessing payment information. If stored client-side, it can be stolen via XSS attacks, browser extensions, or shared/public computers. An attacker with this token can impersonate the shopper.
VtexIdclientAutCookieDetection
If you see referenced in , , or assigned to a JavaScript variable in client-side code → STOP immediately. The token must be managed exclusively server-side.
VtexIdclientAutCookielocalStorage.setItemsessionStorage.setItemCorrect
typescript
// 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);
});Wrong
typescript
// 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}` },
});
}VtexIdclientAutCookielocalStoragesessionStorage重要性
VtexIdclientAutCookie检测方式
如果你在客户端代码中看到被用于、或赋值给JavaScript变量→立即停止。该令牌必须完全由服务器端管理。
VtexIdclientAutCookielocalStorage.setItemsessionStorage.setItem正确示例
typescript
// BFF路由——将VtexIdclientAutCookie存储在服务器端会话中
import { Router, Request, Response } from "express";
import session from "express-session";
const router = Router();
// VTEX登录回调后,提取并在服务器端存储令牌
router.get("/auth/callback", async (req: Request, res: Response) => {
const vtexAuthToken = req.cookies["VtexIdclientAutCookie"];
if (!vtexAuthToken) {
return res.status(401).json({ error: "认证失败" });
}
// 存储在服务器端会话中——绝不会发送至前端
req.session.vtexAuthToken = vtexAuthToken;
// 清除浏览器响应中的Cookie
res.clearCookie("VtexIdclientAutCookie");
// 重定向至带有安全会话Cookie的前端页面
res.redirect("/account");
});
// BFF代理使用会话令牌调用VTEX API
router.get("/api/bff/profile", async (req: Request, res: Response) => {
const vtexToken = req.session.vtexAuthToken;
if (!vtexToken) {
return res.status(401).json({ error: "未认证" });
}
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);
});错误示例
typescript
// 前端代码——将认证令牌存储在localStorage中(安全漏洞)
function handleLoginCallback() {
const params = new URLSearchParams(window.location.search);
const vtexToken = params.get("authToken");
// 错误:令牌现在可被页面上的任何JS访问,包括XSS攻击载荷
localStorage.setItem("VtexIdclientAutCookie", vtexToken!);
}
// 后续从localStorage读取并在请求头中发送
async function getProfile() {
const token = localStorage.getItem("VtexIdclientAutCookie"); // 已暴露!
return fetch("https://mystore.vtexcommercestable.com.br/api/checkout/pub/profiles", {
headers: { Cookie: `VtexIdclientAutCookie=${token}` },
});
}Constraint: API keys MUST NOT appear in client-side code
约束:API密钥禁止出现在客户端代码中
VTEX_APP_KEYVTEX_APP_TOKENWhy this matters
API keys grant programmatic access to the VTEX platform with the permissions of their associated role. Exposing them in frontend bundles, public directories, or client-side environment variables (e.g., , ) allows anyone to extract them and make unauthorized API calls.
NEXT_PUBLIC_*VITE_*Detection
If you see , , , or in files under , , directories, or in environment variables prefixed with , , or → STOP immediately. Move these to server-side-only environment variables.
VTEX_APP_KEYVTEX_APP_TOKENX-VTEX-API-AppKeyX-VTEX-API-AppTokensrc/public/app/NEXT_PUBLIC_VITE_REACT_APP_Correct
typescript
// 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;Wrong
typescript
// .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();
}VTEX_APP_KEYVTEX_APP_TOKEN重要性
API密钥可凭借其关联角色的权限,以编程方式访问VTEX平台。如果在前端打包文件、公共目录或客户端环境变量(如、)中暴露这些密钥,任何人都可提取它们并发起未授权的API调用。
NEXT_PUBLIC_*VITE_*检测方式
如果你在、、目录下的文件中,或在带有、、前缀的环境变量中看到、、或→立即停止。将这些凭证迁移至仅服务器端可访问的环境变量中。
src/public/app/NEXT_PUBLIC_VITE_REACT_APP_VTEX_APP_KEYVTEX_APP_TOKENX-VTEX-API-AppKeyX-VTEX-API-AppToken正确示例
typescript
// BFF服务器代码——仅从服务器端环境变量读取密钥
// 文件:server/vtex-client.ts(绝不会为浏览器打包)
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: "获取订单失败" });
}
const order = await response.json();
res.json(order);
});
export default router;错误示例
typescript
// 带有NEXT_PUBLIC_前缀的.env文件——会暴露给浏览器打包文件!
// NEXT_PUBLIC_VTEX_APP_KEY=vtexappkey-mystore-ABCDEF
// NEXT_PUBLIC_VTEX_APP_TOKEN=eyJhbGciOi...
// 前端代码读取暴露的环境变量
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!, // 在打包文件中暴露!
"X-VTEX-API-AppToken": process.env.NEXT_PUBLIC_VTEX_APP_TOKEN!, // 在打包文件中暴露!
},
}
);
return response.json();
}Preferred pattern
推荐模式
Architecture overview — how requests flow through the BFF:
text
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 servicesMinimal BFF server setup with session management:
typescript
// 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({
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 client with credential injection for both auth types:
typescript
// 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 route handler with session-based auth and input validation:
typescript
// 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" });
}
});Authentication flow with server-side token management:
typescript
// 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层流转:
text
前端(浏览器/应用)
│
├── 直接调用(允许):Intelligent Search API(公开、只读)
│
└── 所有其他请求 → BFF层(Node.js/Express)
│
├── 从会话中注入VtexIdclientAutCookie
├── 注入X-VTEX-API-AppKey / X-VTEX-API-AppToken
├── 验证并清理输入
└── 代理至VTEX APIs
│
├── 结账API (/api/checkout/pub/...)
├── OMS API (/api/oms/pvt/...)
├── 个人资料API (/api/profile-system/pvt/...)
└── 其他VTEX服务带会话管理的最小BFF服务器配置:
typescript
// 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({
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小时(与VtexIdclientAutCookie的TTL匹配)
},
})
);
// 挂载BFF路由
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服务器运行在端口${PORT}`);
});支持两种认证类型的VTEX API客户端(自动注入凭证):
typescript
// 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错误:${response.status} ${response.statusText} for ${method} ${path}`
);
}
return response.json() as Promise<T>;
}基于会话认证并包含输入验证的BFF路由处理器:
typescript
// server/routes/orders.ts
import { Router, Request, Response } from "express";
import { vtexRequest } from "../vtex-api-client";
export const ordersRoutes = Router();
// 获取订单详情——需要API密钥认证(私有端点)
ordersRoutes.get("/:orderId", async (req: Request, res: Response) => {
try {
const { orderId } = req.params;
// 验证输入
if (!/^[a-zA-Z0-9-]+$/.test(orderId)) {
return res.status(400).json({ error: "无效的订单ID格式" });
}
// 可选:检查用户会话以验证权限
const vtexToken = req.session.vtexAuthToken;
if (!vtexToken) {
return res.status(401).json({ error: "需要认证" });
}
const order = await vtexRequest({
path: `/api/oms/pvt/orders/${orderId}`,
authType: "app-key",
});
res.json(order);
} catch (error) {
console.error("获取订单错误:", error);
res.status(500).json({ error: "获取订单失败" });
}
});基于服务器端令牌管理的认证流程:
typescript
// 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!;
// 重定向购物者至VTEX登录页面
authRoutes.get("/login", (_req: Request, res: Response) => {
const returnUrl = `${FRONTEND_URL}/auth/callback`;
res.redirect(`${VTEX_LOGIN_URL}?returnUrl=${encodeURIComponent(returnUrl)}`);
});
// 处理登录回调——提取VtexIdclientAutCookie并在服务器端存储
authRoutes.get("/callback", (req: Request, res: Response) => {
const vtexToken = req.cookies["VtexIdclientAutCookie"];
if (!vtexToken) {
return res.redirect(`${FRONTEND_URL}/login?error=auth_failed`);
}
// 将令牌存储在服务器端会话中
req.session.vtexAuthToken = vtexToken;
// 清除浏览器中的VTEX Cookie
res.clearCookie("VtexIdclientAutCookie");
// 重定向至已认证的前端页面
res.redirect(`${FRONTEND_URL}/account`);
});
// 登出——销毁会话
authRoutes.post("/logout", (req: Request, res: Response) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: "登出失败" });
}
res.clearCookie("connect.sid");
res.json({ success: true });
});
});
// 检查认证状态
authRoutes.get("/status", (req: Request, res: Response) => {
res.json({
authenticated: !!req.session.vtexAuthToken,
});
});Common failure modes
常见失败模式
-
Proxying Intelligent Search through BFF: Routing every VTEX API call through the BFF, including Intelligent Search, adds unnecessary latency and server load. Intelligent Search is a public, read-only API designed for direct frontend consumption. Call it directly from the frontend.typescript
// 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(); } -
Sharing a single API key across all BFF operations: Using one API key with broad permissions (e.g., Owner role) for all BFF operations means a compromised key grants access to every VTEX resource. Create separate API keys for different BFF modules with minimal required permissions.typescript
// 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; -
Logging API credentials or auth tokens: Logging request headers or full request objects during debugging inadvertently writes API keys orvalues to log files, which may be accessible to multiple team members or attackers. Sanitize all log output to strip sensitive headers before logging.
VtexIdclientAutCookietypescript// 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代理Intelligent Search:将所有VTEX API调用都通过BFF路由(包括Intelligent Search)会增加不必要的延迟和服务器负载。Intelligent Search是专为前端直接调用设计的公开、只读API,应直接由前端调用。typescript
// 前端代码——直接调用Intelligent Search(这是正确的!) 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密钥:使用单个权限宽泛的API密钥(如Owner角色)用于所有BFF操作,意味着一旦密钥泄露,攻击者可访问所有VTEX资源。应为不同的BFF模块创建具有最小必要权限的独立API密钥。typescript
// server/vtex-credentials.ts——按领域拆分密钥 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密钥或值写入日志文件,这些文件可能被多名团队成员或攻击者访问。在记录前,应清理所有日志输出以移除敏感请求头。
VtexIdclientAutCookietypescript// 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(); }
Review checklist
审查清单
- Is a BFF layer present? Every headless VTEX project requires one — no exceptions.
- Are all private VTEX API calls (endpoints) routed through the BFF?
/pvt/ - Are and
VTEX_APP_KEYstored exclusively in server-side environment variables?VTEX_APP_TOKEN - Are API keys absent from any ,
NEXT_PUBLIC_*, orVITE_*environment variables?REACT_APP_* - Is stored in a server-side session, not in
VtexIdclientAutCookieorlocalStorage?sessionStorage - Is Intelligent Search called directly from the frontend (not unnecessarily proxied through BFF)?
- Are separate API keys used for different BFF modules with minimal permissions?
- Are sensitive headers redacted from all log output?
- 是否配备了BFF层?每个无头VTEX项目都必须配备——无例外。
- 所有私有VTEX API调用(端点)是否都通过BFF路由?
/pvt/ - 和
VTEX_APP_KEY是否仅存储在服务器端环境变量中?VTEX_APP_TOKEN - API密钥是否未出现在任何、
NEXT_PUBLIC_*或VITE_*环境变量中?REACT_APP_* - 是否存储在服务器端会话中,而非
VtexIdclientAutCookie或localStorage?sessionStorage - Intelligent Search是否由前端直接调用(未不必要地通过BFF代理)?
- 是否为不同的BFF模块使用了具有最小权限的独立API密钥?
- 所有日志输出是否都已移除敏感请求头?
Reference
参考资料
- Headless commerce overview — Core architecture guide for building headless stores on VTEX
- Headless authentication — OAuth-based shopper authentication flow for headless implementations
- API authentication using API keys — How to use appKey/appToken pairs for machine authentication
- API authentication using user tokens — How VtexIdclientAutCookie works and its scopes
- Refresh token flow for headless implementations — How to refresh expired VtexIdclientAutCookie tokens
- Best practices for using application keys — VTEX security guidelines for API key management
- 无头电商概述 —— 基于VTEX构建无头商店的核心架构指南
- 无头认证 —— 无头实现的基于OAuth的购物者认证流程
- 使用API密钥进行API认证 —— 如何使用appKey/appToken对进行机器认证
- 使用用户令牌进行API认证 —— VtexIdclientAutCookie的工作原理及其作用范围
- 无头实现的刷新令牌流程 —— 如何刷新过期的VtexIdclientAutCookie令牌
- 应用密钥的最佳实践 —— VTEX关于API密钥管理的安全指南