headless-bff-architecture

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

BFF 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
    VtexIdclientAutCookie
    (stored server-side) for shopper-scoped API calls. Use
    X-VTEX-API-AppKey
    /
    X-VTEX-API-AppToken
    for machine-to-machine calls.
  • Classify APIs by their path:
    /pub/
    endpoints are public but most still need BFF proxying for session management;
    /pvt/
    endpoints are private and must go through BFF.
  • Even public Checkout endpoints (
    /api/checkout/pub/
    ) must be proxied through BFF for security — they handle sensitive personal data.
  • 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进行分类:
    /pub/
    端点为公开API,但多数仍需通过BFF代理以实现会话管理;
    /pvt/
    端点为私有API,必须通过BFF路由。
  • 即使是公开的结账端点(
    /api/checkout/pub/
    )也必须通过BFF代理,因为它们处理敏感的个人数据。
  • 为不同的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
X-VTEX-API-AppKey
and
X-VTEX-API-AppToken
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.
Detection
If you see
fetch
or
axios
calls to
vtexcommercestable.com.br/api/checkout
,
/api/oms
,
/api/profile
, or any
/pvt/
endpoint in client-side code (files under
src/
,
public/
,
app/
, or any browser-executed bundle) → STOP immediately. These calls must be moved to the BFF.
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需要
X-VTEX-API-AppKey
X-VTEX-API-AppToken
请求头。如果前端直接调用这些API,凭证必须嵌入客户端代码或传输至浏览器,任何打开浏览器开发者工具的用户都能获取到这些凭证。被盗的API密钥可用于访问订单数据、修改定价或执行破坏性的管理操作。
检测方式
如果你在客户端代码(
src/
public/
app/
目录下的文件,或任何浏览器可执行的打包文件)中看到
fetch
axios
调用
vtexcommercestable.com.br/api/checkout
/api/oms
/api/profile
或任何
/pvt/
端点→立即停止。这些调用必须迁移至BFF层。
正确示例
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
VtexIdclientAutCookie
token MUST be stored in a secure server-side session (e.g., encrypted cookie, Redis session store) and MUST NOT be stored in
localStorage
,
sessionStorage
, or any client-accessible JavaScript variable.
Why this matters
The
VtexIdclientAutCookie
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.
Detection
If you see
VtexIdclientAutCookie
referenced in
localStorage.setItem
,
sessionStorage.setItem
, or assigned to a JavaScript variable in client-side code → STOP immediately. The token must be managed exclusively server-side.
Correct
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}` },
  });
}

VtexIdclientAutCookie
令牌必须存储在安全的服务器端会话中(如加密Cookie、Redis会话存储),禁止存储在
localStorage
sessionStorage
或任何客户端可访问的JavaScript变量中。
重要性
VtexIdclientAutCookie
是代表购物者认证所有操作的承载令牌——包括下单、查看个人资料数据、访问支付信息。如果存储在客户端,可能会通过XSS攻击、浏览器扩展或共享/公共电脑被盗取。攻击者获取该令牌后可冒充购物者。
检测方式
如果你在客户端代码中看到
VtexIdclientAutCookie
被用于
localStorage.setItem
sessionStorage.setItem
或赋值给JavaScript变量→立即停止。该令牌必须完全由服务器端管理。
正确示例
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_KEY
and
VTEX_APP_TOKEN
values MUST only exist in server-side environment variables and MUST NOT be present in any file that is bundled, served, or accessible to the browser.
Why 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.,
NEXT_PUBLIC_*
,
VITE_*
) allows anyone to extract them and make unauthorized API calls.
Detection
If you see
VTEX_APP_KEY
,
VTEX_APP_TOKEN
,
X-VTEX-API-AppKey
, or
X-VTEX-API-AppToken
in files under
src/
,
public/
,
app/
directories, or in environment variables prefixed with
NEXT_PUBLIC_
,
VITE_
, or
REACT_APP_
→ STOP immediately. Move these to server-side-only environment variables.
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_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
→立即停止。将这些凭证迁移至仅服务器端可访问的环境变量中。
正确示例
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 services
Minimal 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 or
    VtexIdclientAutCookie
    values to log files, which may be accessible to multiple team members or attackers. Sanitize all log output to strip sensitive headers before logging.
    typescript
    // 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密钥或
    VtexIdclientAutCookie
    值写入日志文件,这些文件可能被多名团队成员或攻击者访问。在记录前,应清理所有日志输出以移除敏感请求头。
    typescript
    // 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 (
    /pvt/
    endpoints) routed through the BFF?
  • Are
    VTEX_APP_KEY
    and
    VTEX_APP_TOKEN
    stored exclusively in server-side environment variables?
  • Are API keys absent from any
    NEXT_PUBLIC_*
    ,
    VITE_*
    , or
    REACT_APP_*
    environment variables?
  • Is
    VtexIdclientAutCookie
    stored in a server-side session, not in
    localStorage
    or
    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调用(
    /pvt/
    端点)是否都通过BFF路由?
  • VTEX_APP_KEY
    VTEX_APP_TOKEN
    是否仅存储在服务器端环境变量中?
  • API密钥是否未出现在任何
    NEXT_PUBLIC_*
    VITE_*
    REACT_APP_*
    环境变量中?
  • VtexIdclientAutCookie
    是否存储在服务器端会话中,而非
    localStorage
    sessionStorage
  • Intelligent Search是否由前端直接调用(未不必要地通过BFF代理)?
  • 是否为不同的BFF模块使用了具有最小权限的独立API密钥?
  • 所有日志输出是否都已移除敏感请求头?

Reference

参考资料