Loading...
Loading...
Apply when designing or modifying a BFF (Backend-for-Frontend) layer, middleware, or API proxy for a headless VTEX storefront. Covers BFF middleware architecture, public vs private API classification, VtexIdclientAutCookie management, API key protection, and secure request proxying. Use for any headless commerce project that must never expose VTEX_APP_KEY or call private VTEX APIs from the browser.
npx skill4agent add vtexdocs/ai-skills headless-bff-architectureVTEX_APP_KEYVTEX_APP_TOKENVtexIdclientAutCookieheadless-checkout-proxyheadless-intelligent-searchheadless-caching-strategyVtexIdclientAutCookieX-VTEX-API-AppKeyX-VTEX-API-AppToken/pub//pvt//api/checkout/pub/X-VTEX-API-AppKeyX-VTEX-API-AppTokenfetchaxiosvtexcommercestable.com.br/api/checkout/api/oms/api/profile/pvt/src/public/app/// 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();
}VtexIdclientAutCookielocalStoragesessionStorageVtexIdclientAutCookieVtexIdclientAutCookielocalStorage.setItemsessionStorage.setItem// 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}` },
});
}VTEX_APP_KEYVTEX_APP_TOKENNEXT_PUBLIC_*VITE_*VTEX_APP_KEYVTEX_APP_TOKENX-VTEX-API-AppKeyX-VTEX-API-AppTokensrc/public/app/NEXT_PUBLIC_VITE_REACT_APP_// 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();
}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// 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}`);
});// 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>;
}// 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,
});
});// 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();
}// 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;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();
}/pvt/VTEX_APP_KEYVTEX_APP_TOKENNEXT_PUBLIC_*VITE_*REACT_APP_*VtexIdclientAutCookielocalStoragesessionStorage