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

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

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.

  1. Open the site’s settings from the gear icon in the workspace.

  2. In the left sidebar choose Apps & integrations and scroll to the API access section.

  3. Select Generate API token.

  4. Name the token (use a name that identifies the script, for example fernbrook-sync) and tick the scopes it needs.

  5. Select Generate token, copy it at once and store it in a secret manager. The value is shown only at creation.

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.

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})`);
}

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})")

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.

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.

  1. In the workspace choose Settings > Apps & Integrations > App Development > Create an App.

  2. Enter a name, a description of at most 140 characters, an icon and an HTTPS homepage URL.

  3. Choose the capability: a Designer Extension, a Data Client, or both (a hybrid app, see Hybrid Apps). OAuth needs the Data Client capability.

  4. Select the scopes the app needs. These are the scopes it can ever request.

  5. 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.

  6. Copy the Client ID and Client Secret from the app’s listing.

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 scope parameter 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.

sequenceDiagram autonumber actor U as Agency editor participant B as Browser participant A as Dashboard server participant W as Webflow authorize page participant T as Webflow token endpoint participant D as Data API v2 U->>B: Click "Connect Webflow" B->>A: GET /auth/webflow A->>A: Create random state, store it in the session A-->>B: 302 to webflow.com/oauth/authorize (client_id, scope, state, redirect_uri) B->>W: Follow redirect W->>U: Show app name and requested scopes U->>W: Choose sites and approve W-->>B: 302 to redirect_uri with code and state B->>A: GET /auth/webflow/callback?code=...&state=... A->>A: Compare state with the session value A->>T: POST /oauth/access_token (client_id, client_secret, code, redirect_uri) T-->>A: access_token A->>A: Store the token encrypted, tied to the user A->>D: GET /v2/token/introspect (Bearer token) D-->>A: scopes and authorized site IDs A->>D: GET /v2/sites (Bearer token) D-->>A: Sites the user authorized A-->>B: Dashboard page

The numbered steps in detail:

  1. Authorize URL. The server redirects the browser to https://webflow.com/oauth/authorize with these query parameters.

    Parameter Required Meaning

    client_id

    Yes

    The app’s client ID from its listing

    response_type

    Yes

    Always code

    redirect_uri

    Documented as required

    Must match a registered URI. If sent here, it must be sent again, unchanged, in the token request

    state

    No, but use it

    An unguessable value that Webflow returns untouched; the server compares it to prevent cross-site request forgery

    scope

    Configured per app

    Space-separated scope names; a colon is written %3A and a space %20 when the URL is built by hand

    Webflow 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.

  2. Approval. Webflow shows the user the app and the scopes and lets them choose the sites (or the workspace) to authorize.

  3. Callback. Webflow redirects to the registered redirect URI with a code and the state. The code is single-use and valid for 15 minutes, so exchange it immediately.

  4. Token exchange. The server posts client_id, client_secret, code and, if it was used in the authorize link, redirect_uri to https://api.webflow.com/oauth/access_token. The client secret is sent only from the server, never from a browser or a mobile app.

  5. 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

400 Bad Request on the authorize link

Wrong client_id, a scope not registered in the app, a redirect_uri that does not match exactly, or incorrect URL encoding of the scope list

invalid_grant on the exchange

The code was already used or is older than 15 minutes; redirect_uri was sent in the authorize link but not in the exchange; the client credentials are wrong. Restart the flow to get a fresh code

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 SameSite policy, or a restart of the server with in-memory sessions). Treat as an attack and abort

API calls return 403 after a successful install

The scope in the authorize URL omitted something the app needs, or the app gained a scope after the user installed it and the user has not re-authorized

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"
  }
}

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

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 state is 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 401 from 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

sites:read, sites:write

Site

List and read sites, domains and settings; publish, and modify site data

pages:read, pages:write

Site

Read pages and their metadata and DOM; update page metadata and content

components:read, components:write

Site

Read and update components, their content and properties

cms:read, cms:write

Site

Read and modify collections, fields and items (staged and live)

assets:read, assets:write

Site

Read assets and folders; upload and modify assets

forms:read, forms:write

Site

Read forms and submissions; modify or delete them

custom_code:read, custom_code:write

Site

Read and modify registered scripts and their application to sites and pages (Data Client apps only)

ecommerce:read, ecommerce:write

Site

Read and modify products, SKUs, orders, inventory and settings

comments:read, comments:write

Site

Read and write comment threads

authorized_user:read

Site

Read the user who authorized the token (/v2/token/authorized_by)

site_activity:read

Site

Read the site activity log

site_config:read, site_config:write

Site

Read and modify site configuration such as redirects and robots rules (Enterprise features)

site_plans:read, site_plans:write

Site

Read and modify the site plan

users:read, users:write

Site

Read and manage the site’s Memberships users

branches:read, branches:create, branches:update, branches:delete, branches:merge, branches:publish

Site

Page-branch operations; see Page Branching, Releases and Approvals

workspace:write

Site

Modify the workspace

workspace:read

Workspace

Read the workspace

workspace_activity:read

Workspace

Read the workspace audit log (the only scope of a workspace token)

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

fernbrook-sync pushing Plants and Events

List collections, create, update, publish and delete items

cms:read, cms:write

Nightly asset audit

List assets and folders

assets:read

Form-submission digest for the volunteers coordinator

List forms and submissions

forms:read

Content Dashboard (OAuth)

List sites, read the authorized user, read and write items

sites:read, authorized_user:read, cms:read, cms:write

Redirect manager for the migration

Read and write redirects (Enterprise)

site_config:read, site_config:write

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"
  }
}

Field by field:

  • authorization.id, createdOn, lastUsed: the identity and age of the authorization. A lastUsed far in the past on an API token marks a candidate for revocation.

  • authorization.grantType: authorization_code for OAuth tokens and client_credentials for 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 of siteIds, workspaceIds and userIds the token reaches.

  • application: present only for OAuth app tokens, with the app’s id, displayName, description and homepage. 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);
}

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)

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

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 .env files that are committed, not in a CI configuration file. A .env file 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 Authorization header.

  • One token per integration. The five-token limit per site is generous enough for it. A token per integration means a precise lastUsed for 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 401 in 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"),
});

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 }}

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.

  1. Prefer read scopes. A reporting script needs cms:read, not cms:write. Add write only to the job that changes content.

  2. 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.

  3. 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.

  4. Restrict app installation to a single site when the app serves one client at a time, using the toggle in the app settings.

  5. Ask for scopes in the authorize URL that match the app’s real calls, and verify with introspection that the grant matches the request.

  6. Review. Run the introspection script on a schedule and revoke tokens whose lastUsed is 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.

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 api.webflow.com/v2, described on this page

Webflow CLI login (webflow auth login)

An interactive authorization of the @webflow/webflow-cli tool to your account; see Webflow CLI

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

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

custom_code:* is reserved for apps

Local experiments

Site token in a Developer workspace

Free and quick; delete it when done

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_uri in 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.scope on 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:read only, 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, state and 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/introspect reports a token’s grant type, scopes, rate limit and the sites, workspaces and users it reaches; GET /v2/token/authorized_by returns the authorizing user and needs authorized_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.