Enterprise-grade access control for Webflow Data API v2 integrations. Uses Webflow's OAuth 2.0 scope system, per-site token isolation, and application-level RBAC to enforce least privilege across teams and environments.
Webflow provides three levels of access control:
| Level | Mechanism | Granularity |
|---|---|---|
| Workspace tokens | API token | All sites in workspace |
| Site tokens | API token | Single site |
| OAuth tokens | OAuth 2.0 authorization | User-authorized scopes per site |
Webflow scopes map directly to API access:
| Scope | Read Access | Write Access |
|---|---|---|
sites:read |
List/get sites | — |
sites:write |
— | Publish sites |
cms:read |
List collections, read items | — |
cms:write |
— | Create/update/delete CMS items |
pages:read |
List/get pages | — |
pages:write |
— | Update page settings |
forms:read |
List forms, read submissions | — |
ecommerce:read |
List products, orders, inventory | — |
ecommerce:write |
— | Create products, fulfill orders |
custom_code:read |
List registered scripts | — |
custom_code:write |
— | Register/apply custom code |
Map your application roles to Webflow scope sets:
enum AppRole {
ContentViewer = "content_viewer",
ContentEditor = "content_editor",
SiteAdmin = "site_admin",
EcommerceManager = "ecommerce_manager",
FormProcessor = "form_processor",
}
// Map roles to required Webflow OAuth scopes
const ROLE_SCOPES: Record<AppRole, string[]> = {
[AppRole.ContentViewer]: ["sites:read", "cms:read", "pages:read"],
[AppRole.ContentEditor]: ["sites:read", "cms:read", "cms:write", "pages:read"],
[AppRole.SiteAdmin]: [
"sites:read", "sites:write",
"cms:read", "cms:write",
"pages:read", "pages:write",
"custom_code:read", "custom_code:write",
],
[AppRole.EcommerceManager]: [
"sites:read",
"ecommerce:read", "ecommerce:write",
"cms:read", // Products are CMS collections
],
[AppRole.FormProcessor]: ["sites:read", "forms:read"],
};
Request only the scopes needed for the user's role:
function getAuthorizationUrl(role: AppRole, state: string): string {
const scopes = ROLE_SCOPES[role];
const scopeString = scopes.join(" ");
return (
`https://webflow.com/oauth/authorize` +
`?client_id=${process.env.WEBFLOW_CLIENT_ID}` +
`&response_type=code` +
`&redirect_uri=${encodeURIComponent(process.env.REDIRECT_URI!)}` +
`&scope=${encodeURIComponent(scopeString)}` +
`&state=${state}` // CSRF protection
);
}
// Initiate OAuth flow with role-appropriate scopes
app.get("/auth/webflow", (req, res) => {
const role = req.user.appRole as AppRole;
const state = generateCsrfToken(req.session.id);
res.redirect(getAuthorizationUrl(role, state));
});
interface StoredToken {
accessToken: string;
userId: string;
role: AppRole;
scopes: string[];
siteIds: string[]; // Which sites this token can access
createdAt: Date;
lastUsed: Date;
}
async function storeToken(token: StoredToken): Promise<void> {
// Encrypt token before storing
const encrypted = encrypt(token.accessToken);
await db.webflowTokens.upsert({
where: { userId: token.userId },
create: {
...token,
accessToken: encrypted,
},
update: {
accessToken: encrypted,
lastUsed: new Date(),
},
});
}
Use site-scoped tokens to limit blast radius:
class SiteIsolatedClient {
private clients = new Map<string, WebflowClient>();
// Each site gets its own token — compromise of one doesn't affect others
registerSite(siteId: string, siteToken: string): void {
this.clients.set(siteId, new WebflowClient({ accessToken: siteToken }));
}
getClient(siteId: string): WebflowClient {
const client = this.clients.get(siteId);
if (!client) {
throw new Error(`No token registered for site ${siteId}`);
}
return client;
}
// Rotate a single site's token without affecting others
rotateToken(siteId: string, newToken: string): void {
this.clients.set(siteId, new WebflowClient({ accessToken: newToken }));
console.log(`Token rotated for site ${siteId}`);
}
}
const siteClients = new SiteIsolatedClient();
siteClients.registerSite("site-prod", process.env.WEBFLOW_TOKEN_PROD!);
siteClients.registerSite("site-staging", process.env.WEBFLOW_TOKEN_STAGING!);
import { Request, Response, NextFunction } from "express";
function requireWebflowScope(requiredScopes: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const userToken = await db.webflowTokens.findByUserId(req.user.id);
if (!userToken) {
return res.status(401).json({ error: "No Webflow authorization" });
}
// Check if user's token has the required scopes
const missingScopes = requiredScopes.filter(
s => !userToken.scopes.includes(s)
);
if (missingScopes.length > 0) {
return res.status(403).json({
error: "Insufficient Webflow permissions",
missing: missingScopes,
userRole: userToken.role,
hint: `Role "${userToken.role}" does not include: ${missingScopes.join(", ")}`,
});
}
next();
};
}
// Usage
app.post(
"/api/cms/items",
requireWebflowScope(["cms:write"]),
createItemHandler
);
app.get(
"/api/forms/submissions",
requireWebflowScope(["forms:read"]),
listSubmissionsHandler
);
app.post(
"/api/site/publish",
requireWebflowScope(["sites:write"]),
publishSiteHandler
);
interface WebflowAuditEntry {
timestamp: Date;
userId: string;
role: AppRole;
operation: string;
siteId: string;
resourceType: string;
resourceId?: string;
result: "success" | "denied" | "error";
scopes: string[];
ipAddress: string;
}
async function auditWebflowAccess(entry: WebflowAuditEntry): Promise<void> {
// Write to audit log (never delete audit entries)
await db.auditLog.create({
data: {
...entry,
timestamp: entry.timestamp.toISOString(),
},
});
// Alert on suspicious patterns
if (entry.result === "denied") {
console.warn(`ACCESS DENIED: ${entry.userId} attempted ${entry.operation} on ${entry.resourceType}`);
}
// Alert on admin operations
if (entry.role === AppRole.SiteAdmin && entry.operation.includes("publish")) {
console.log(`ADMIN ACTION: ${entry.userId} published site ${entry.siteId}`);
}
}
// Wrap API calls with audit logging
async function auditedCall<T>(
entry: Omit<WebflowAuditEntry, "timestamp" | "result">,
operation: () => Promise<T>
): Promise<T> {
try {
const result = await operation();
await auditWebflowAccess({ ...entry, timestamp: new Date(), result: "success" });
return result;
} catch (error) {
await auditWebflowAccess({ ...entry, timestamp: new Date(), result: "error" });
throw error;
}
}
async function checkTokenAge(): Promise<void> {
const tokens = await db.webflowTokens.findMany();
for (const token of tokens) {
const ageInDays = (Date.now() - token.createdAt.getTime()) / (1000 * 60 * 60 * 24);
if (ageInDays > 90) {
console.warn(
`Token for user ${token.userId} (${token.role}) is ${Math.floor(ageInDays)} days old. ` +
`Rotation recommended.`
);
// Send notification to user/admin
}
}
}
// Run weekly
// cron: "0 9 * * 1"
| Issue | Cause | Solution |
|---|---|---|
| OAuth scope mismatch | App requests more scopes than configured | Match app settings in Webflow dashboard |
| 403 on API call | Token missing required scope | Re-authorize with correct role |
| Token not found | User never completed OAuth flow | Redirect to authorization |
| Audit gap | Error in async logging | Add fallback logging to console |
For major migrations, see webflow-migration-deep-dive.