Data API Authentication and Scopes
|
This content was generated with the assistance of AI and should be verified against the official Webflow documentation before being relied on in production. This sub-section’s Webflow bibliography lists the reference material consulted while preparing these pages. |
This page describes Webflow Data API authentication as it was on 2026-10-28, the date on which its facts were checked
against the official documentation. The Data API is the v2 REST API at https://api.webflow.com/v2. The examples use
the JavaScript SDK webflow-api 3.3.4 (Node 18 or later; the Webflow CLI 2.15.0 needs Node 22.13 or later) and the Python SDK
webflow 2.0.0 from PyPI (Python 3.8 or later). Creating a site token needs a site on any plan
and the administrator role of that site; workspace tokens need an Enterprise workspace; registering an app (needed for
OAuth) is a Workspace feature that the developer documentation lists for the Team and Enterprise tiers. Plans are named
by tier only, and every example runs against a site in a free Developer workspace. The
Developer Platform Overview explains how to create that workspace.
The running example is Fernbrook Gardens, the fictitious botanical garden of the whole sub-section. This page covers
the two ways the garden’s team gets credentials: an internal script, fernbrook-sync, that uses a site token for the
garden’s own site, and a small OAuth app, the Fernbrook Content Dashboard, that the garden’s agency installs on
several client sites and that therefore needs the authorization-code flow.
Why authentication has three shapes
Every Data API request carries a bearer token in the Authorization header. What differs is who issued the token, what
it can reach and how long it lives. The documentation distinguishes three kinds:
| Kind | Reach | Typical use | Issued by |
|---|---|---|---|
Site token |
One site |
Internal tools, scripts and single-site integrations in an environment you control |
A site administrator, in Site settings |
Workspace token |
One Enterprise workspace, and only the workspace audit-log endpoint |
Monitoring and auditing across the workspace |
A workspace administrator, in Workspace settings |
OAuth access token |
The sites and workspace the installing user selected, limited to the scopes the app declares |
Public integrations, Marketplace apps and anything that must act for several sites or users |
Webflow, after the user authorizes the app |
A token is a long opaque string. It is a credential in the full sense: anybody holding it can do whatever its scopes allow on the sites it covers, until it is revoked. Nothing about the token reveals its scopes to the holder, which is why the introspection endpoint described later exists.
The decision usually reduces to two questions. First, does the code belong to the site owner, running in an environment the owner controls? Then a site token is the simplest option. Second, does the code need to reach sites that belong to other people, or reach several sites with different owners? Then an OAuth app is the only supported option, because a site token is limited to one site and only a site administrator can create it.
The request header
All three kinds travel the same way:
GET /v2/sites HTTP/1.1
Host: api.webflow.com
Authorization: Bearer REPLACE_WITH_API_TOKEN
Accept: application/json
Source: Data API: Authentication
Header names are case-insensitive, so a lowercase authorization also works. The same request with curl and an
environment variable keeps the secret out of the shell history when the variable is exported from a file that is not
committed:
export WEBFLOW_API_TOKEN="$(cat ~/.config/fernbrook/webflow-token)"
curl --silent --show-error \
--header "Authorization: Bearer ${WEBFLOW_API_TOKEN}" \
--header "Accept: application/json" \
https://api.webflow.com/v2/sites
Source: Data API: Authentication
A request without a token, or with a token that has been revoked or never existed, is answered with 401. A request
whose token is valid but lacks the scope the endpoint needs is answered with 403. Keeping those two apart is the first
step of debugging: 401 means fix the credential, 403 means fix the scopes (or the resource the token is allowed to
reach).
Site tokens
A site token is a bearer token tied to a single site. It is the lowest-friction way to call the Data API and the one
the Fernbrook team uses for fernbrook-sync.
Creating a site token
Only a site administrator can create one. In the Developer workspace the owner is the administrator of every site they create, so the steps below work immediately.
-
Open the site’s settings from the gear icon in the workspace.
-
In the left sidebar choose Apps & integrations and scroll to the API access section.
-
Select Generate API token.
-
Name the token (use a name that identifies the script, for example
fernbrook-sync) and tick the scopes it needs. -
Select Generate token, copy it at once and store it in a secret manager. The value is shown only at creation.
Source: Data API: Site token
Rules and limits of site tokens
-
Five per site. Each site can have up to five tokens. Give each integration its own token so that revoking one does not break the others.
-
One site. A token works only for the site it was created on. An integration spanning several sites should be an app, which is what the documentation recommends.
-
Fixed scopes. The scopes are chosen when the token is generated. To grant more, generate a new token and retire the old one.
-
Inactivity expiry. A token expires after 365 consecutive days without use. Any API call with the token resets the period. Otherwise it stays valid until it is revoked, so a forgotten token in a repository is a long-lived problem.
-
Endpoint exclusions. A site token cannot call the authorization endpoints, the custom-code endpoints or the workspace activity-log endpoints. Custom code through the API is reserved for apps; see Data API: Assets, Forms and Custom Code.
-
Immediate revocation. In Site settings > Apps & integrations > API access select the token and revoke it; it stops working at once.
Source: Data API: Site token
A first call with a site token
The smallest useful program lists the sites the token can see and prints their names and IDs. In Node:
import { WebflowClient } from "webflow-api";
const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN });
const { sites } = await client.sites.list();
for (const site of sites ?? []) {
console.log(`${site.displayName} (${site.id})`);
}
Source: Data API: List Sites
The Python equivalent:
import os
from webflow.client import Webflow
client = Webflow(access_token=os.environ["WEBFLOW_API_TOKEN"])
for site in client.sites.list().sites or []:
print(f"{site.display_name} ({site.id})")
Source: Data API: List Sites
A site token is bound to one site, so the list contains exactly one entry: the Fernbrook Gardens developer site.
Everything else in the sub-section assumes this ID is exported as the environment variable WEBFLOW_SITE_ID; the
examples use the placeholder REPLACE_WITH_SITE_ID where an ID is written out.
Workspace tokens
A workspace token belongs to a workspace rather than a site. Its purpose is narrow and Enterprise-only.
-
Only workspace administrators can create one, and only in an Enterprise workspace.
-
It carries a single scope,
workspace_activity:read, and does not include any site scope, so it cannot reach site-specific endpoints. -
Its endpoint is the workspace audit-log (activity) endpoint, which returns activity across the whole workspace.
-
It expires after 365 consecutive days without use; each call resets the period. Each workspace can have up to five.
-
Tokens cannot be regenerated. If the value is lost, create a new token and update every integration that used the old one.
Source: Data API: Workspace token
A security team that exports the audit log to its SIEM is the typical consumer; the governance side is covered in Enterprise APIs and Governance. For Fernbrook Gardens, which is not an Enterprise customer, workspace tokens are described here only so that the three shapes can be told apart.
OAuth apps
When code must act on behalf of users, on sites it does not own, the Data API uses the OAuth 2.0 authorization code grant. The code that runs the flow is a Data Client app: an app registered in a workspace that declares the scopes it wants, a redirect URI and an installation mode.
Registering the app
Only workspace administrators can create apps, view client secrets, upload bundles and change app settings. The requirements the documentation lists are admin rights in the workspace, a verified email and two-factor authentication.
-
In the workspace choose Settings > Apps & Integrations > App Development > Create an App.
-
Enter a name, a description of at most 140 characters, an icon and an HTTPS homepage URL.
-
Choose the capability: a Designer Extension, a Data Client, or both (a hybrid app, see Hybrid Apps). OAuth needs the Data Client capability.
-
Select the scopes the app needs. These are the scopes it can ever request.
-
Register one or more redirect URIs. Each must be a valid HTTPS URL that matches, character for character, what the app later sends, including a trailing slash or a localhost port. Redirect URIs can be added or changed after registration.
-
Copy the Client ID and Client Secret from the app’s listing.
Source: Apps: Register an app
Several rules in that page matter for the design:
-
The Restrict app installation to a specific site toggle limits each authorization to a single site. With it off (the default) a user can authorize the app for several sites or a whole workspace.
-
A new app is available only to users in its own workspace. External users cannot install it until it is approved and published in the Marketplace, although external test users can be invited beforehand. A dedicated development workspace is the recommended place to build; a free Developer workspace is available for that.
-
The scopes are locked once the app is published. Adding a scope means submitting an App Update for approval, and only after approval should the
scopeparameter in the OAuth URL change; changing it early causes a scopes-mismatch error. Existing installs do not gain the new scope, so the app has to prompt users to re-authorize or reinstall. -
The client secret belongs in an environment variable or a secret manager, never in version control. If it is exposed, rotate it.
The publishing side is in Publishing Apps and the Marketplace, and the app model in Apps Overview and OAuth.
The authorization-code flow
The flow has four steps: send the user to Webflow, receive a short-lived code on the redirect URI, exchange the code for an access token on the server, and call the API with that token. The diagram shows the actors in the Fernbrook Content Dashboard.
The numbered steps in detail:
-
Authorize URL. The server redirects the browser to
https://webflow.com/oauth/authorizewith these query parameters.Parameter Required Meaning client_idYes
The app’s client ID from its listing
response_typeYes
Always
coderedirect_uriDocumented as required
Must match a registered URI. If sent here, it must be sent again, unchanged, in the token request
stateNo, but use it
An unguessable value that Webflow returns untouched; the server compares it to prevent cross-site request forgery
scopeConfigured per app
Space-separated scope names; a colon is written
%3Aand a space%20when the URL is built by handWebflow rejects an authorize URL that asks for scopes beyond those configured in the app settings, but it does not check that the scopes are sufficient: leave one out and authorization succeeds while the API calls that need it fail with
403. The Install button in the app’s settings generates a ready-made install link for testing. -
Approval. Webflow shows the user the app and the scopes and lets them choose the sites (or the workspace) to authorize.
-
Callback. Webflow redirects to the registered redirect URI with a
codeand thestate. The code is single-use and valid for 15 minutes, so exchange it immediately. -
Token exchange. The server posts
client_id,client_secret,codeand, if it was used in the authorize link,redirect_uritohttps://api.webflow.com/oauth/access_token. The client secret is sent only from the server, never from a browser or a mobile app. -
Use. Every Data API call carries the returned token as a bearer token.
Source: Data API: OAuth
The documentation states the 15-minute lifetime of the code. It does not describe PKCE, refresh tokens or an expiry time
for the access token, so the flow here is the classic confidential-client one: the secret stays on the server, and the
server must be ready for a 401 at any time and send the user through the flow again. The general concepts behind the
flow are explained, independently of Webflow, in Authorization Code
and PKCE and Scopes, Claims and Permissions.
Common authorize and exchange errors
| Symptom | Usual cause and fix |
|---|---|
|
Wrong |
|
The code was already used or is older than 15 minutes; |
State mismatch in your own callback |
The session cookie was lost (different host name for the callback than for the start of the flow, a |
API calls return |
The |
Source: Data API: OAuth
A full OAuth example in Node (Express)
The Fernbrook Content Dashboard backend below implements the flow end to end: it starts the authorization, validates the
state, exchanges the code, checks the granted scopes with the introspection endpoint, stores the token and lists the
sites. It uses express, express-session and the JavaScript SDK.
The package.json pins the versions that were current on 2026-10-28:
{
"name": "fernbrook-content-dashboard",
"private": true,
"type": "module",
"engines": { "node": ">=18" },
"scripts": { "start": "node server.mjs" },
"dependencies": {
"express": "5.3.0",
"express-session": "1.18.1",
"webflow-api": "3.3.4"
}
}
Source: npm: webflow-api
The environment holds the only secrets. Create the file locally, never commit it, and load it with
node --env-file=.env server.mjs or the process manager of your host:
# .env (not committed)
WEBFLOW_CLIENT_ID=REPLACE_WITH_CLIENT_ID
WEBFLOW_CLIENT_SECRET=REPLACE_WITH_CLIENT_SECRET
WEBFLOW_REDIRECT_URI=https://localhost:3000/auth/webflow/callback
SESSION_SECRET=REPLACE_WITH_LONG_RANDOM_STRING
Source: Apps: Register an app
The redirect URI of a Data Client must be HTTPS, so for local development either serve the app over HTTPS or register a tunnel URL. Tunnel hosts that change on every restart must be re-registered each time.
// server.mjs: OAuth authorization-code flow for the Fernbrook Content Dashboard
import crypto from "node:crypto";
import express from "express";
import session from "express-session";
import { WebflowClient } from "webflow-api";
const {
WEBFLOW_CLIENT_ID: clientId,
WEBFLOW_CLIENT_SECRET: clientSecret,
WEBFLOW_REDIRECT_URI: redirectUri,
SESSION_SECRET: sessionSecret,
} = process.env;
for (const [name, value] of Object.entries({
WEBFLOW_CLIENT_ID: clientId,
WEBFLOW_CLIENT_SECRET: clientSecret,
WEBFLOW_REDIRECT_URI: redirectUri,
SESSION_SECRET: sessionSecret,
})) {
if (!value) throw new Error(`Missing environment variable ${name}`);
}
// Ask only for what the dashboard uses: read sites, read and write CMS items.
const SCOPES = ["authorized_user:read", "sites:read", "cms:read", "cms:write"];
const app = express();
app.use(
session({
secret: sessionSecret,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: "lax", maxAge: 30 * 60 * 1000 },
}),
);
// In production this is an encrypted database column, not a Map.
const tokenVault = new Map();
app.get("/auth/webflow", (req, res) => {
const state = crypto.randomBytes(32).toString("hex");
req.session.oauthState = state;
const authorizeUrl = WebflowClient.authorizeURL({
clientId,
redirectUri,
scope: SCOPES,
state,
});
res.redirect(authorizeUrl);
});
app.get("/auth/webflow/callback", async (req, res) => {
const { code, state, error } = req.query;
if (error) return res.status(400).send(`Authorization refused: ${error}`);
if (typeof code !== "string" || typeof state !== "string") {
return res.status(400).send("Missing code or state");
}
const expected = req.session.oauthState;
delete req.session.oauthState; // one use only
const a = Buffer.from(state);
const b = Buffer.from(expected ?? "");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).send("State does not match");
}
try {
const accessToken = await WebflowClient.getAccessToken({
clientId,
clientSecret,
code,
redirectUri,
});
const client = new WebflowClient({ accessToken });
// What did the user actually grant, and where?
const introspection = await client.token.introspect();
const granted = (introspection.authorization?.scope ?? "").split(",");
const missing = SCOPES.filter((scope) => !granted.includes(scope));
if (missing.length > 0) {
return res.status(403).send(`Missing scopes: ${missing.join(", ")}`);
}
// Persist per user. Never put the token in the cookie, the URL or the page.
const userId = introspection.authorization?.authorizedTo?.userIds?.[0];
tokenVault.set(userId, accessToken);
req.session.userId = userId;
res.redirect("/dashboard");
} catch (err) {
console.error("Token exchange failed", err?.statusCode ?? "", err?.message);
res.status(502).send("Could not complete the Webflow connection");
}
});
app.get("/dashboard", async (req, res) => {
const accessToken = tokenVault.get(req.session.userId);
if (!accessToken) return res.redirect("/auth/webflow");
const client = new WebflowClient({ accessToken });
try {
const { sites } = await client.sites.list();
res.json((sites ?? []).map(({ id, displayName }) => ({ id, displayName })));
} catch (err) {
if (err?.statusCode === 401) {
tokenVault.delete(req.session.userId); // revoked or invalid: reconnect
return res.redirect("/auth/webflow");
}
throw err;
}
});
app.post("/auth/webflow/disconnect", express.json(), async (req, res) => {
const accessToken = tokenVault.get(req.session.userId);
if (accessToken) {
await fetch("https://webflow.com/oauth/revoke_authorization", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
access_token: accessToken,
}),
});
tokenVault.delete(req.session.userId);
}
req.session.destroy(() => res.status(204).end());
});
app.listen(3000, () => console.log("Fernbrook Content Dashboard on :3000"));
Source: Data API: OAuth
Points to notice in the example:
-
The
stateis a random 256-bit value stored in the server-side session, compared in constant time and deleted after the first attempt, so a replayed callback fails. -
The introspection call proves that the token carries the scopes the dashboard needs before anything is stored, and tells the server which site IDs the user chose.
-
The cookie never carries the Webflow token; it carries only the session identifier.
-
A
401from the API is handled as "the user revoked access or the token is invalid": the stored token is dropped and the user is sent through the flow again. -
The disconnect route calls the revocation endpoint, described below, before it forgets the token.
The same flow in Python
The Python SDK offers the same two helpers. This version uses Flask, which keeps the example short; any framework works the same way. The dependencies, pinned on 2026-10-28:
Flask==3.1.0
webflow==2.0.0
requests==2.32.3
Source: PyPI: webflow
# app.py: OAuth authorization-code flow for the Fernbrook Content Dashboard
import hmac
import os
import secrets
import requests
from flask import Flask, jsonify, redirect, request, session
from webflow.client import Webflow
from webflow.oauth import authorize_url, get_access_token
CLIENT_ID = os.environ["WEBFLOW_CLIENT_ID"]
CLIENT_SECRET = os.environ["WEBFLOW_CLIENT_SECRET"]
REDIRECT_URI = os.environ["WEBFLOW_REDIRECT_URI"]
# Ask only for what the dashboard uses.
SCOPES = ["authorized_user:read", "sites:read", "cms:read", "cms:write"]
app = Flask(__name__)
app.secret_key = os.environ["SESSION_SECRET"]
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE="Lax",
)
# In production this is an encrypted database column, not a dict.
TOKEN_VAULT: dict[str, str] = {}
@app.get("/auth/webflow")
def start_authorization():
state = secrets.token_hex(32)
session["oauth_state"] = state
return redirect(
authorize_url(
client_id=CLIENT_ID,
redirect_uri=REDIRECT_URI,
scope=SCOPES,
state=state,
)
)
@app.get("/auth/webflow/callback")
def callback():
if "error" in request.args:
return f"Authorization refused: {request.args['error']}", 400
code = request.args.get("code")
state = request.args.get("state", "")
expected = session.pop("oauth_state", "") # one use only
if not code or not hmac.compare_digest(state, expected):
return "State does not match", 400
access_token = get_access_token(
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
code=code,
redirect_uri=REDIRECT_URI,
)
client = Webflow(access_token=access_token)
introspection = client.token.introspect()
authorization = introspection.authorization
granted = (authorization.scope or "").split(",")
missing = [scope for scope in SCOPES if scope not in granted]
if missing:
return f"Missing scopes: {', '.join(missing)}", 403
user_id = authorization.authorized_to.user_ids[0]
TOKEN_VAULT[user_id] = access_token # never in the cookie
session["user_id"] = user_id
return redirect("/dashboard")
@app.get("/dashboard")
def dashboard():
access_token = TOKEN_VAULT.get(session.get("user_id", ""))
if not access_token:
return redirect("/auth/webflow")
client = Webflow(access_token=access_token)
sites = client.sites.list().sites or []
return jsonify([{"id": s.id, "displayName": s.display_name} for s in sites])
@app.post("/auth/webflow/disconnect")
def disconnect():
user_id = session.get("user_id", "")
access_token = TOKEN_VAULT.pop(user_id, None)
if access_token:
requests.post(
"https://webflow.com/oauth/revoke_authorization",
json={
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"access_token": access_token,
},
timeout=10,
)
session.clear()
return "", 204
Source: Data API: OAuth
The structure is identical to the Node version on purpose. The Python SDK exposes the authorization helpers in
webflow.oauth and the client class Webflow in webflow.client; the SDK’s asynchronous client, AsyncWebflow, takes
the same access_token argument and is the right choice inside an async framework such as FastAPI. The SDKs, retries
and error classes are covered in
Data API: SDKs, Limits and Errors.
Revoking an OAuth token
A token can be revoked programmatically, which invalidates it immediately. The request is a POST to
https://webflow.com/oauth/revoke_authorization (note the host: webflow.com, not api.webflow.com) with a JSON body
holding client_id, client_secret and the access_token:
curl --silent --show-error --request POST \
--url https://webflow.com/oauth/revoke_authorization \
--header "Content-Type: application/json" \
--data "{
\"client_id\": \"${WEBFLOW_CLIENT_ID}\",
\"client_secret\": \"${WEBFLOW_CLIENT_SECRET}\",
\"access_token\": \"${ACCESS_TOKEN_TO_REVOKE}\"
}"
Source: Data API: OAuth
A successful call answers 200 with:
{ "did_revoke": true }
Source: Data API: OAuth
The documented errors are invalid_client (wrong client ID or secret), invalid_token (the token does not exist or was
already revoked) and invalid_request (missing or malformed parameters). Treat invalid_token on a disconnect as
success: the goal, a dead token, is already reached. Users can also remove an app from the site or workspace settings,
after which the token stops working and the next API call returns 401.
Site tokens have no revoke endpoint in this API; they are revoked by a site administrator in Site settings > Apps & integrations > API access.
The scope list
Scopes are the units of permission. A token carries a fixed list of them, an endpoint documents the scope it needs, and
a mismatch produces 403. Scope names follow resource:action, where the action is read or write (and, for
branches, finer actions). The reference lists the following on 2026-10-28:
| Scope | Kind | Lets the token |
|---|---|---|
|
Site |
List and read sites, domains and settings; publish, and modify site data |
|
Site |
Read pages and their metadata and DOM; update page metadata and content |
|
Site |
Read and update components, their content and properties |
|
Site |
Read and modify collections, fields and items (staged and live) |
|
Site |
Read assets and folders; upload and modify assets |
|
Site |
Read forms and submissions; modify or delete them |
|
Site |
Read and modify registered scripts and their application to sites and pages (Data Client apps only) |
|
Site |
Read and modify products, SKUs, orders, inventory and settings |
|
Site |
Read and write comment threads |
|
Site |
Read the user who authorized the token ( |
|
Site |
Read the site activity log |
|
Site |
Read and modify site configuration such as redirects and robots rules (Enterprise features) |
|
Site |
Read and modify the site plan |
|
Site |
Read and manage the site’s Memberships users |
|
Site |
Page-branch operations; see Page Branching, Releases and Approvals |
|
Site |
Modify the workspace |
|
Workspace |
Read the workspace |
|
Workspace |
Read the workspace audit log (the only scope of a workspace token) |
Source: Data API: Scopes
The reference does not define each scope in a sentence; the third column describes the resource family each name
covers, and every endpoint page states the exact scope it requires, which is the authoritative source when in doubt.
Webhook scopes depend on the trigger type being subscribed to and are not listed by name; the authorization endpoints
need no scope. Some scopes are unavailable to some token kinds: custom_code:* is for apps only, and workspace tokens
carry just workspace_activity:read.
Which scopes do the Fernbrook jobs need?
Choosing scopes is a design exercise: list the endpoints, read each page’s scope, and take the union.
| Job | Endpoints it calls | Scopes |
|---|---|---|
|
List collections, create, update, publish and delete items |
|
Nightly asset audit |
List assets and folders |
|
Form-submission digest for the volunteers coordinator |
List forms and submissions |
|
Content Dashboard (OAuth) |
List sites, read the authorized user, read and write items |
|
Redirect manager for the migration |
Read and write redirects (Enterprise) |
|
Source: Data API: Scopes
The fernbrook-sync token does not get sites:write, so a bug or a leak cannot publish the whole site or change its
domains; it does not get forms:read, so a leak cannot expose visitors' form submissions. Splitting the jobs across
tokens (the limit is five per site) turns each scope decision into a separate blast radius.
Token introspection
Once a token exists, two endpoints answer the questions "what is this token?" and "who authorized it?".
Get authorization info
GET https://api.webflow.com/v2/token/introspect accepts a bearer token from a Data Client app, a workspace token or a
site token and returns what Webflow knows about it. The page lists no scope requirement beyond a valid token. The
response has an authorization object and, for OAuth app tokens only, an application object:
{
"authorization": {
"id": "55818d58616600637b9a5786",
"createdOn": "2026-10-03T23:12:00.755Z",
"lastUsed": "2026-10-10T21:41:12.736Z",
"grantType": "authorization_code",
"rateLimit": 60,
"scope": "authorized_user:read,sites:read,cms:read,cms:write",
"authorizedTo": {
"siteIds": ["REPLACE_WITH_SITE_ID"],
"workspaceIds": ["REPLACE_WITH_WORKSPACE_ID"],
"userIds": ["REPLACE_WITH_USER_ID"]
}
},
"application": {
"id": "REPLACE_WITH_APP_ID",
"displayName": "Fernbrook Content Dashboard",
"description": "Agency dashboard for Fernbrook Gardens",
"homepage": "https://dashboard.example.com"
}
}
Source: Data API: Get Authorization Info
Field by field:
-
authorization.id,createdOn,lastUsed: the identity and age of the authorization. AlastUsedfar in the past on an API token marks a candidate for revocation. -
authorization.grantType:authorization_codefor OAuth tokens andclient_credentialsfor site and workspace API tokens. -
authorization.rateLimit: the default rate limit in requests per minute for this authorization. -
authorization.scope: the granted scopes as one comma-separated string. Split on the comma, not on a space. -
authorization.authorizedTo: arrays ofsiteIds,workspaceIdsanduserIdsthe token reaches. -
application: present only for OAuth app tokens, with the app’sid,displayName,descriptionandhomepage. It is omitted for site and workspace API tokens.
There is no separate user field; user IDs appear only in authorizedTo.userIds. A 401 means the token is invalid.
A small audit script uses it to print the scopes of a site token before it is trusted in a pipeline:
import { WebflowClient } from "webflow-api";
const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN });
const { authorization } = await client.token.introspect();
console.log("grant type:", authorization?.grantType);
console.log("scopes :", authorization?.scope);
console.log("sites :", authorization?.authorizedTo?.siteIds);
const allowed = new Set(["cms:read", "cms:write"]);
const extra = (authorization?.scope ?? "")
.split(",")
.filter((scope) => scope && !allowed.has(scope));
if (extra.length > 0) {
console.error(`Token has more scopes than the job needs: ${extra.join(", ")}`);
process.exit(1);
}
Source: Data API: Get Authorization Info
The same check in Python:
import os
import sys
from webflow.client import Webflow
client = Webflow(access_token=os.environ["WEBFLOW_API_TOKEN"])
authorization = client.token.introspect().authorization
print("grant type:", authorization.grant_type)
print("scopes :", authorization.scope)
print("sites :", authorization.authorized_to.site_ids)
allowed = {"cms:read", "cms:write"}
extra = [s for s in (authorization.scope or "").split(",") if s and s not in allowed]
if extra:
print(f"Token has more scopes than the job needs: {', '.join(extra)}", file=sys.stderr)
sys.exit(1)
Source: Data API: Get Authorization Info
Running this as the first step of a CI job turns "least privilege" from a policy into a check: if somebody regenerates the token with extra scopes, the pipeline stops.
Get the authorizing user
GET https://api.webflow.com/v2/token/authorized_by returns the id, email, firstName and lastName of the user who
authorized the token. It needs the authorized_user:read scope; without it the API answers 403.
curl --silent --show-error \
--header "Authorization: Bearer ${WEBFLOW_API_TOKEN}" \
https://api.webflow.com/v2/token/authorized_by
Source: Data API: Get Authorized User
The response contains personal data (an email address), so treat it as such: show the name in the dashboard if needed, do not log the email, and do not request the scope in jobs that do not need it.
Storing tokens safely
A leaked Webflow token is a content-integrity and privacy incident: whoever holds a cms:write token can change what the
garden’s visitors read, and a forms:read token exposes the people who wrote to the garden. The practices below apply
to every kind of token and to the OAuth client secret.
-
Never in the repository. Not in source, not in
.envfiles that are committed, not in a CI configuration file. A.envfile belongs in.gitignore, and a secret scanner (see Testing, CI and DevOps) should run on every push. Rotating a token is cheap; rewriting git history is not. -
Environment variables or a secret manager. The official guidance is to use environment variables, not source code. In CI use the platform’s encrypted secrets; on a server use the cloud’s secret manager and inject at start-up.
-
Server side only. A token in browser JavaScript is public to every visitor. The Content Delivery API tokens in Content Delivery API and Headless follow the same rule: even a read-only token should stay on the server.
-
Encrypted at rest for OAuth tokens. Store them per user in a database column encrypted with a key held outside the database, and keep the decryption key in the secret manager. Do not put tokens in cookies, local storage, URLs or logs.
-
Redaction in logs. Log the token’s last four characters at most. Make HTTP client debug logging opt-in, because it prints the
Authorizationheader. -
One token per integration. The five-token limit per site is generous enough for it. A token per integration means a precise
lastUsedfor each and a revocation that breaks one thing. -
Rotate on a schedule and on suspicion. Generate the new token, deploy it, confirm with introspection, then revoke the old one. Rotate immediately if a token appears in a log, a screenshot or a ticket.
-
Watch for inactivity. A token unused for 365 days expires, which also means an unmonitored job can silently stop. Alert on
401in scheduled jobs.
A minimal pattern for a scheduled job reads the token only from the environment and fails closed:
import { WebflowClient } from "webflow-api";
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
console.error(`Missing ${name}`);
process.exit(2);
}
return value;
}
export const client = new WebflowClient({
accessToken: requireEnv("WEBFLOW_API_TOKEN"),
});
Source: Data API: Authentication
A GitHub Actions job injects the secret and nothing else; the permissions of the workflow are reduced to the minimum:
name: fernbrook-sync
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: node scripts/check-token-scopes.mjs
env:
WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_API_TOKEN }}
- run: node scripts/sync.mjs
env:
WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_API_TOKEN }}
WEBFLOW_SITE_ID: ${{ vars.WEBFLOW_SITE_ID }}
Source: Data API: Authentication
The site ID is not a secret and lives in a repository variable; the token is a secret.
Least privilege in practice
Least privilege is the habit of asking for the smallest set of scopes, on the narrowest token, for the shortest time. For the Data API it breaks down into six rules.
-
Prefer read scopes. A reporting script needs
cms:read, notcms:write. Addwriteonly to the job that changes content. -
Split by job. One site token for the sync, one for the nightly audit, one for the agency’s staging pipeline. A leak of the audit token cannot change content.
-
Prefer the narrow token kind. A site token for one site beats an OAuth app that can reach a whole workspace. Use OAuth only when the code truly crosses site or user boundaries.
-
Restrict app installation to a single site when the app serves one client at a time, using the toggle in the app settings.
-
Ask for scopes in the authorize URL that match the app’s real calls, and verify with introspection that the grant matches the request.
-
Review. Run the introspection script on a schedule and revoke tokens whose
lastUsedis old or whose scopes exceed the job’s needs.
The scope list also interacts with the plan: some endpoints (Enterprise site configuration, workspace audit logs) work
only on Enterprise workspaces regardless of scope. A 403 on such an endpoint after the scopes were checked usually means
the plan, not the token.
Rate limits are per key
Authentication also decides how fast a client may go. The limits are applied per API key: 60 requests per minute for the
Starter and Basic tiers, 120 for the CMS, Ecommerce and Business tiers and a custom value for Enterprise, with the
headers X-RateLimit-Limit, X-RateLimit-Remaining and, when exceeded, a 429 with Retry-After (typically 60
seconds). Site publish has an additional limit of one successful publish per minute. The introspection response reports
the default limit for the authorization in authorization.rateLimit. Two tokens on the same site do not share a budget in
the way one token does, which is a further reason to give each job its own token, but a script that spreads a bulk job
across tokens to dodge the limit is working against the platform’s intent; use batching and backoff instead. The full
treatment is in Data API: SDKs, Limits and Errors.
Source: Data API: Rate limits
The CLI and other credentials
The Data API tokens above are not the only credentials in the Webflow ecosystem, and mixing them up is a common source of confusion.
| Credential | What it is |
|---|---|
Data API site token, workspace token, OAuth token |
Bearer tokens for |
Webflow CLI login ( |
An interactive authorization of the |
Webhook signing secret |
The key Webflow uses to sign deliveries; it is not an API credential; see Webhooks |
Content Delivery API token |
A read-only token for the cached delivery host; see Content Delivery API and Headless |
Source: Data API: Authentication
Choosing a method: a decision table
| Situation | Use | Why |
|---|---|---|
The garden’s own script updates the garden’s own site |
Site token |
One site, one administrator, no flow to build |
The agency’s dashboard manages several client sites |
OAuth app |
Many sites and users; per-install consent; revocable by the user |
A security team exports the workspace audit log |
Workspace token (Enterprise) |
Read-only, one scope, no site access |
A Marketplace app for other workspaces |
OAuth app, published |
Only apps can be installed by external users |
Registering a script or reading custom code through the API |
OAuth app |
|
Local experiments |
Site token in a Developer workspace |
Free and quick; delete it when done |
Source: Data API: Authentication
Pitfalls checklist
-
Using a site token for a task that needs the custom-code or authorization endpoints and getting
403; use an app. -
Registering a redirect URI with a trailing slash and sending it without one (or the reverse): the match is exact.
-
Forgetting to send
redirect_uriin the token exchange when it was in the authorize link (invalid_grant). -
Letting the 15-minute code expire while the user waits on a consent screen of your own: exchange it first.
-
Splitting
authorization.scopeon spaces instead of commas. -
Adding a scope to the authorize URL before the app update that adds it is approved (scopes-mismatch error).
-
Assuming existing installs gain new scopes: they do not; ask users to re-authorize.
-
Keeping the token in a cookie, a URL or a log line.
-
Sharing one site token between ten scripts and then being unable to revoke any of them.
-
Letting a token lapse after 365 idle days without monitoring.
-
Sending the client secret from a browser or a single-page app: the exchange is a server-side step.
Summary
-
The Data API accepts bearer tokens of three kinds: site token (one site, up to five per site, administrator-issued), workspace token (Enterprise only,
workspace_activity:readonly, up to five per workspace) and OAuth access token (apps, many sites and users). -
Site and workspace tokens expire after 365 days of inactivity; any call resets it. Revocation is immediate.
-
The OAuth authorization-code flow is: authorize URL with
client_id,response_type=code,redirect_uri,stateand scopes; a single-use code valid for 15 minutes; a server-side exchange at/oauth/access_token; revocation at/oauth/revoke_authorization. -
Scopes follow
resource:action; the token must carry what each endpoint requires, and scopes are locked once an app is published. -
GET /v2/token/introspectreports a token’s grant type, scopes, rate limit and the sites, workspaces and users it reaches;GET /v2/token/authorized_byreturns the authorizing user and needsauthorized_user:read. -
Store tokens in a secret manager, keep them on the server, never log them, give each job its own token and verify its scopes with introspection.