Diagnostic API Fundamentals

OAuth token silently stops working after app scopes change

Everything was fine yesterday. Today every call your script makes to the BigCommerce API returns a plain 401 Unauthorized, nothing else changed in your code, and the token has not been touched. What actually happened is upstream: someone edited the app's declared scopes in the Developer Portal, and BigCommerce quietly invalidated the access token you have been holding onto. There is no distinct error for this. It looks exactly like a revoked or expired token unless you go looking for the difference.

Python and Node.js BigCommerce OAuth / Auth Callback Flag only, no silent token minting
Man stands by a typewriter stand under a colorful umbrella.
Photo by Abhishek Ravi on Unsplash
The short answer

BigCommerce invalidates a stored OAuth access token whenever the app's declared scopes are changed in the app or Developer Portal profile. The token is only actually replaced the next time the merchant reopens the app and re-consents through the /auth callback, which returns a fresh access_token plus the new scope string. Until that happens, any script still holding the old token gets a generic 401 Unauthorized on every call, with no distinct "scope changed" error code. Call a lightweight endpoint like GET /v3/catalog/products?limit=1 as a canary, and on a 401 compare the scopes you minted the token with against the scopes the app currently requires. A mismatch means scope drift, a match after one retry means the token was revoked or expired. Either way, this is not auto-fixable. The fix is to flag the store_hash and, if not in dry run, hand the merchant a re-auth link. Full code, tests, and a dry run guard are below.

The problem in plain words

A BigCommerce single-click app gets an access token once, at install time, through the OAuth flow: the merchant is redirected to https://login.bigcommerce.com/oauth2/authorize, consents to the scopes the app declares, and BigCommerce calls the app's auth callback with a code that gets exchanged at POST https://login.bigcommerce.com/oauth2/token for an access_token and a scope string describing exactly what that token can touch. Most integrations store that token once and use it for months.

The part that catches people off guard is that the token is not permanently tied to the app's code. It is tied to the scopes the app declared at the moment the merchant consented. If a developer later edits the app's profile in the Developer Portal, for example adding store_v2_customers because a new feature needs it, BigCommerce treats every existing installation as needing fresh consent. It invalidates the tokens those stores were issued under the old scope set. The store still has the app installed. Nothing in the merchant's world visibly changed. But the token your script has cached in a .env file or a token-store record is now dead weight, and the next call it makes comes back 401 Unauthorized, the same status code you would get from a token that was simply revoked or left to expire.

Scopes edited in Developer Portal Old token invalidated by BigCommerce Script keeps using it 401 Unauthorized Looks just like revoked or expired
Nothing about the 401 tells you the scopes changed. It reads exactly like a revoked or expired token unless you compare scopes yourself.

Why it happens

BigCommerce ties an installation's access token to the exact scope set the merchant consented to at auth time, not to the app's identity alone. A few ways this actually bites in production:

This is documented behavior of the OAuth flow itself, not a bug: BigCommerce's auth guide is explicit that scope changes require the merchant to go through the auth callback again. See the citations at the end for the exact docs and support threads.

The key insight

A 401 by itself tells you nothing about why. The only way to tell scope drift apart from plain revocation or expiry is to compare the scopes the token was minted with against the scopes the app currently requires. If the required set has anything the stored set is missing, that is scope drift, force re-auth immediately. If the scopes still match, allow exactly one retry to rule out a transient blip, and only then call it revoked or expired. Either outcome ends the same way: this cannot be repaired by minting a new token in the background, because BigCommerce will not issue one without the merchant re-consenting through /auth.

The fix, as a flow

We do not attempt to silently refresh or mint a token. We add a canary call, classify the failure with a small pure function, and only ever produce a report plus, if enabled, a re-auth link for a human to send.

Canary call GET /v3/catalog/products 401 received? no: status is OK Compare scopes stored vs required Missing a required scope? yes no, retry once then flag Flag store_hash report re-auth URL
Scope drift or plain revocation both end at the same place: flag the store and report a re-auth link. The script never tries to mint a new token itself.

Build it step by step

1

Get a store hash, an access token, and your app's required scopes

You need the store hash and the access token your app was issued, sent as the X-Auth-Token header on every call. You also need the scope string your app currently requires, which lives in the Developer Portal app profile, and ideally the scope string you captured the last time this store completed the OAuth token exchange. Keep all of it in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export BIGCOMMERCE_STORED_SCOPES="store_v2_orders store_v2_products"
export BIGCOMMERCE_REQUIRED_SCOPES="store_v2_orders store_v2_products store_v2_customers"
export BIGCOMMERCE_CLIENT_ID="..."
export DRY_RUN="true"   # start safe, change to false to emit the re-auth URL
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export BIGCOMMERCE_STORED_SCOPES="store_v2_orders store_v2_products"
export BIGCOMMERCE_REQUIRED_SCOPES="store_v2_orders store_v2_products store_v2_customers"
export BIGCOMMERCE_CLIENT_ID="..."
export DRY_RUN="true"   // start safe, change to false to emit the re-auth URL
2

Call a lightweight canary endpoint

Any cheap authenticated V3 call works. GET /v3/catalog/products?limit=1 or GET /v3/customers?limit=1 are good choices because they are fast and return a small payload. Send X-Auth-Token and Accept: application/json, and capture the status code, do not raise on 401, since that status is exactly the signal we are trying to classify.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Accept": "application/json",
}

def canary_status_code():
    r = requests.get(f"{API_BASE}/catalog/products", headers=HEADERS, params={"limit": 1}, timeout=30)
    return r.status_code
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  Accept: "application/json",
};

async function canaryStatusCode() {
  const url = new URL(`${API_BASE}/catalog/products`);
  url.searchParams.set("limit", "1");
  const res = await fetch(url, { headers: HEADERS });
  return res.status;
}
3

Load the stored scopes and the required scopes

The stored scope string is whatever your token-store record captured at the last successful POST https://login.bigcommerce.com/oauth2/token exchange, the API returns it in the response body's scope field. The required scope string is the app's current scope manifest, the same thing the Developer Portal shows on the app profile. Both are space-separated lists, so turn them into sets before comparing.

step3.py
def scope_set(scope_string):
    return {s for s in (scope_string or "").split() if s}

stored_scopes = scope_set(os.environ.get("BIGCOMMERCE_STORED_SCOPES", ""))
required_scopes = scope_set(os.environ.get("BIGCOMMERCE_REQUIRED_SCOPES", ""))
step3.js
function scopeSet(scopeString) {
  return new Set((scopeString || "").split(/\s+/).filter(Boolean));
}

const storedScopes = scopeSet(process.env.BIGCOMMERCE_STORED_SCOPES || "");
const requiredScopes = scopeSet(process.env.BIGCOMMERCE_REQUIRED_SCOPES || "");
4

Classify the failure with one pure function

Keep the decision in its own function that takes the status code, the stored scopes, the required scopes, and a retry count, and returns one of four outcomes. Scope drift always wins over a plain retry, because there is no point retrying a call that is missing a scope it will never have without re-consent. A clean 401 gets exactly one retry before it is called revoked or expired.

classify.py
def classify_auth_failure(status_code, stored_scopes, required_scopes, retry_count):
    if status_code != 401:
        return "OK"

    if required_scopes - stored_scopes:
        return "SCOPE_DRIFT"

    if retry_count == 0:
        return "TRANSIENT_RETRY"

    return "TOKEN_REVOKED_OR_EXPIRED"
classify.js
function classifyAuthFailure(statusCode, storedScopes, requiredScopes, retryCount) {
  if (statusCode !== 401) return "OK";

  const missing = [...requiredScopes].some((scope) => !storedScopes.has(scope));
  if (missing) return "SCOPE_DRIFT";

  if (retryCount === 0) return "TRANSIENT_RETRY";

  return "TOKEN_REVOKED_OR_EXPIRED";
}
5

Report, never repair, and stop retrying that store

Whatever the classification lands on, other than OK, log the store_hash, the last known scope, the required scope, and the 401 timestamp and count. Do not call the OAuth token endpoint to try to mint a replacement, BigCommerce will refuse it without merchant consent, and quietly retrying forever just burns your rate limit against a store that cannot succeed until a human acts.

apply.py
def reauth_url(client_id, store_hash):
    return (
        "https://login.bigcommerce.com/oauth2/authorize"
        f"?client_id={client_id}&context=stores/{store_hash}"
    )
apply.js
function reauthUrl(clientId, storeHash) {
  return (
    "https://login.bigcommerce.com/oauth2/authorize" +
    `?client_id=${clientId}&context=stores/${storeHash}`
  );
}
6

Wire it together with a dry run guard

The loop ties every piece together: call the canary, classify the result, and on SCOPE_DRIFT or TOKEN_REVOKED_OR_EXPIRED, log the store_hash and, only if DRY_RUN is false, print the merchant-facing re-auth URL so an admin can send it along. On TRANSIENT_RETRY the loop calls the canary exactly one more time before re-classifying. It never loops forever on the same store_hash within a single run.

Run it safe

Always start with DRY_RUN=true. Never let this script attempt to silently mint a replacement token, BigCommerce requires the merchant to re-consent through /auth, and any workaround that tries to bypass that is fighting the platform's own security model. Once a store is flagged, stop calling it until a fresh access_token and scope pair is recorded from a new auth callback.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, calls the canary endpoint, classifies the result with the pure function above, and only ever reports, logging the store_hash and missing scopes and, when not in dry run, the re-auth URL for a human to use.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

check_oauth_scope_drift.py
"""Flag BigCommerce stores whose OAuth token silently died from a scope change.

BigCommerce invalidates a stored OAuth access token whenever the app's declared
scopes change in the app or Developer Portal profile. The token is only actually
replaced the next time the merchant reopens the app and re-consents through the
/auth callback, which returns a fresh access_token plus the new scope string. Any
script still holding the old token gets a generic 401 Unauthorized on every call
afterward, and there is no distinct "scope changed" error code, so scope drift and
plain revocation or expiry look identical unless the caller compares the scopes it
minted the token with against what the app currently requires.

This script calls a lightweight canary endpoint, classifies a 401 as SCOPE_DRIFT,
TRANSIENT_RETRY, or TOKEN_REVOKED_OR_EXPIRED with a pure function, and only ever
reports. It never tries to mint a replacement token itself, because BigCommerce
will not issue one without the merchant re-consenting. Safe to run again and
again, and safe by default with DRY_RUN.

Guide: https://www.allanninal.dev/bigcommerce/oauth-token-invalid-after-scope-change/
"""
import os
import logging

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_oauth_scope_drift")

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
CLIENT_ID = os.environ.get("BIGCOMMERCE_CLIENT_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Accept": "application/json",
}


def scope_set(scope_string):
    return {s for s in (scope_string or "").split() if s}


def classify_auth_failure(
    status_code: int, stored_scopes: set, required_scopes: set, retry_count: int
) -> str:
    """Pure decision logic, no I/O.

    Returns one of: 'OK', 'SCOPE_DRIFT', 'TOKEN_REVOKED_OR_EXPIRED', 'TRANSIENT_RETRY'.
    - If status_code != 401: 'OK'.
    - If 401 and required_scopes - stored_scopes is non-empty: 'SCOPE_DRIFT' (force
      re-auth, no retry).
    - If 401, scopes match, and retry_count == 0: 'TRANSIENT_RETRY' (allow exactly
      one retry).
    - If 401, scopes match, and retry_count >= 1: 'TOKEN_REVOKED_OR_EXPIRED' (force
      re-auth, no further retry).
    """
    if status_code != 401:
        return "OK"

    if required_scopes - stored_scopes:
        return "SCOPE_DRIFT"

    if retry_count == 0:
        return "TRANSIENT_RETRY"

    return "TOKEN_REVOKED_OR_EXPIRED"


def canary_status_code():
    r = requests.get(
        f"{API_BASE}/catalog/products", headers=HEADERS, params={"limit": 1}, timeout=30
    )
    return r.status_code


def reauth_url(client_id, store_hash):
    return (
        "https://login.bigcommerce.com/oauth2/authorize"
        f"?client_id={client_id}&context=stores/{store_hash}"
    )


def run():
    stored_scopes = scope_set(os.environ.get("BIGCOMMERCE_STORED_SCOPES", ""))
    required_scopes = scope_set(os.environ.get("BIGCOMMERCE_REQUIRED_SCOPES", ""))

    retry_count = 0
    status_code = canary_status_code()
    outcome = classify_auth_failure(status_code, stored_scopes, required_scopes, retry_count)

    if outcome == "TRANSIENT_RETRY":
        retry_count = 1
        status_code = canary_status_code()
        outcome = classify_auth_failure(status_code, stored_scopes, required_scopes, retry_count)

    if outcome == "OK":
        log.info("store_hash=%s status=OK canary_status=%s", STORE_HASH, status_code)
        return

    missing_scopes = sorted(required_scopes - stored_scopes)
    log.warning(
        "store_hash=%s classification=%s last_known_scope=%s required_scope=%s "
        "missing_scopes=%s canary_status=%s retry_count=%s",
        STORE_HASH, outcome, sorted(stored_scopes), sorted(required_scopes),
        missing_scopes, status_code, retry_count,
    )

    if not DRY_RUN:
        log.warning(
            "store_hash=%s re_auth_url=%s",
            STORE_HASH, reauth_url(CLIENT_ID, STORE_HASH),
        )

    log.info("Done. store_hash=%s stopping retries until a new access_token/scope pair is recorded.", STORE_HASH)


if __name__ == "__main__":
    run()
check-oauth-scope-drift.js
/**
 * Flag BigCommerce stores whose OAuth token silently died from a scope change.
 *
 * BigCommerce invalidates a stored OAuth access token whenever the app's declared
 * scopes change in the app or Developer Portal profile. The token is only actually
 * replaced the next time the merchant reopens the app and re-consents through the
 * /auth callback, which returns a fresh access_token plus the new scope string. Any
 * script still holding the old token gets a generic 401 Unauthorized on every call
 * afterward, and there is no distinct "scope changed" error code, so scope drift and
 * plain revocation or expiry look identical unless the caller compares the scopes it
 * minted the token with against what the app currently requires.
 *
 * This script calls a lightweight canary endpoint, classifies a 401 as SCOPE_DRIFT,
 * TRANSIENT_RETRY, or TOKEN_REVOKED_OR_EXPIRED with a pure function, and only ever
 * reports. It never tries to mint a replacement token itself, because BigCommerce
 * will not issue one without the merchant re-consenting. Safe to run again and
 * again, and safe by default with DRY_RUN.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/oauth-token-invalid-after-scope-change/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const CLIENT_ID = process.env.BIGCOMMERCE_CLIENT_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  Accept: "application/json",
};

export function scopeSet(scopeString) {
  return new Set((scopeString || "").split(/\s+/).filter(Boolean));
}

/**
 * Pure decision logic, no I/O.
 *
 * Returns one of: 'OK', 'SCOPE_DRIFT', 'TOKEN_REVOKED_OR_EXPIRED', 'TRANSIENT_RETRY'.
 * - If statusCode !== 401: 'OK'.
 * - If 401 and requiredScopes has anything storedScopes lacks: 'SCOPE_DRIFT' (force
 *   re-auth, no retry).
 * - If 401, scopes match, and retryCount === 0: 'TRANSIENT_RETRY' (allow exactly
 *   one retry).
 * - If 401, scopes match, and retryCount >= 1: 'TOKEN_REVOKED_OR_EXPIRED' (force
 *   re-auth, no further retry).
 */
export function classifyAuthFailure(statusCode, storedScopes, requiredScopes, retryCount) {
  if (statusCode !== 401) return "OK";

  const missing = [...requiredScopes].some((scope) => !storedScopes.has(scope));
  if (missing) return "SCOPE_DRIFT";

  if (retryCount === 0) return "TRANSIENT_RETRY";

  return "TOKEN_REVOKED_OR_EXPIRED";
}

async function canaryStatusCode() {
  const url = new URL(`${API_BASE}/catalog/products`);
  url.searchParams.set("limit", "1");
  const res = await fetch(url, { headers: HEADERS });
  return res.status;
}

function reauthUrl(clientId, storeHash) {
  return (
    "https://login.bigcommerce.com/oauth2/authorize" +
    `?client_id=${clientId}&context=stores/${storeHash}`
  );
}

export async function run() {
  const storedScopes = scopeSet(process.env.BIGCOMMERCE_STORED_SCOPES || "");
  const requiredScopes = scopeSet(process.env.BIGCOMMERCE_REQUIRED_SCOPES || "");

  let retryCount = 0;
  let statusCode = await canaryStatusCode();
  let outcome = classifyAuthFailure(statusCode, storedScopes, requiredScopes, retryCount);

  if (outcome === "TRANSIENT_RETRY") {
    retryCount = 1;
    statusCode = await canaryStatusCode();
    outcome = classifyAuthFailure(statusCode, storedScopes, requiredScopes, retryCount);
  }

  if (outcome === "OK") {
    console.log(`store_hash=${STORE_HASH} status=OK canary_status=${statusCode}`);
    return;
  }

  const missingScopes = [...requiredScopes].filter((s) => !storedScopes.has(s)).sort();
  console.warn(
    `store_hash=${STORE_HASH} classification=${outcome} ` +
    `last_known_scope=${[...storedScopes].sort()} required_scope=${[...requiredScopes].sort()} ` +
    `missing_scopes=${missingScopes} canary_status=${statusCode} retry_count=${retryCount}`
  );

  if (!DRY_RUN) {
    console.warn(`store_hash=${STORE_HASH} re_auth_url=${reauthUrl(CLIENT_ID, STORE_HASH)}`);
  }

  console.log(`Done. store_hash=${STORE_HASH} stopping retries until a new access_token/scope pair is recorded.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The classification rule is the part most worth testing, because it decides whether a script keeps hammering a dead token or correctly hands the problem to a human. Because classify_auth_failure takes only plain values and returns a plain string, the test needs no network and no BigCommerce store. It just feeds in plain values and checks the answer.

test_oauth_classify_failure.py
from check_oauth_scope_drift import classify_auth_failure


def test_ok_when_status_is_not_401():
    assert classify_auth_failure(200, {"store_v2_orders"}, {"store_v2_orders"}, 0) == "OK"


def test_scope_drift_when_required_scope_is_missing():
    stored = {"store_v2_orders"}
    required = {"store_v2_orders", "store_v2_customers"}
    assert classify_auth_failure(401, stored, required, 0) == "SCOPE_DRIFT"


def test_scope_drift_wins_even_on_first_attempt():
    stored = {"store_v2_products"}
    required = {"store_v2_products", "store_v2_orders"}
    assert classify_auth_failure(401, stored, required, 0) == "SCOPE_DRIFT"


def test_transient_retry_when_scopes_match_and_first_attempt():
    scopes = {"store_v2_orders", "store_v2_products"}
    assert classify_auth_failure(401, scopes, scopes, 0) == "TRANSIENT_RETRY"


def test_revoked_or_expired_when_scopes_match_after_retry():
    scopes = {"store_v2_orders", "store_v2_products"}
    assert classify_auth_failure(401, scopes, scopes, 1) == "TOKEN_REVOKED_OR_EXPIRED"


def test_revoked_or_expired_stays_final_on_further_retries():
    scopes = {"store_v2_orders"}
    assert classify_auth_failure(401, scopes, scopes, 3) == "TOKEN_REVOKED_OR_EXPIRED"
check-oauth-scope-drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyAuthFailure } from "./check-oauth-scope-drift.js";

test("OK when status is not 401", () => {
  const scopes = new Set(["store_v2_orders"]);
  assert.equal(classifyAuthFailure(200, scopes, scopes, 0), "OK");
});

test("SCOPE_DRIFT when required scope is missing", () => {
  const stored = new Set(["store_v2_orders"]);
  const required = new Set(["store_v2_orders", "store_v2_customers"]);
  assert.equal(classifyAuthFailure(401, stored, required, 0), "SCOPE_DRIFT");
});

test("SCOPE_DRIFT wins even on first attempt", () => {
  const stored = new Set(["store_v2_products"]);
  const required = new Set(["store_v2_products", "store_v2_orders"]);
  assert.equal(classifyAuthFailure(401, stored, required, 0), "SCOPE_DRIFT");
});

test("TRANSIENT_RETRY when scopes match and first attempt", () => {
  const scopes = new Set(["store_v2_orders", "store_v2_products"]);
  assert.equal(classifyAuthFailure(401, scopes, scopes, 0), "TRANSIENT_RETRY");
});

test("TOKEN_REVOKED_OR_EXPIRED when scopes match after retry", () => {
  const scopes = new Set(["store_v2_orders", "store_v2_products"]);
  assert.equal(classifyAuthFailure(401, scopes, scopes, 1), "TOKEN_REVOKED_OR_EXPIRED");
});

test("TOKEN_REVOKED_OR_EXPIRED stays final on further retries", () => {
  const scopes = new Set(["store_v2_orders"]);
  assert.equal(classifyAuthFailure(401, scopes, scopes, 3), "TOKEN_REVOKED_OR_EXPIRED");
});

Case studies

New feature, new scope

The integration that broke for every store overnight

A developer shipped a new feature that needed store_v2_customers and updated the app's scope manifest in the Developer Portal to match. Within minutes, every background sync job across every installed store started failing with 401 Unauthorized. The on-call engineer's first instinct was to check the API status page, then suspect a network issue, because nothing in their own code or credentials had changed.

Running the canary check against a handful of affected stores showed the same pattern every time: the stored scope was missing store_v2_customers, the exact scope that had just been added. Once the classification pointed at SCOPE_DRIFT instead of a generic outage, the team stopped burning time on the wrong theory and started emailing merchants a re-auth link instead.

Revoked, not drifted

The store that had genuinely uninstalled and reinstalled

One flagged store looked identical to the scope-drift cases at first glance, a plain 401 on every call. But the scope comparison came back clean, the stored scopes matched the required scopes exactly. A single retry a few seconds later still came back 401.

That combination told the real story: the merchant had genuinely removed the app from their store, which revokes the token outright, and then reinstalled it under a different internal record the team's token store had not picked up yet. Because the script only reports and never retries a store past that point, no calls were wasted hammering a token that was never coming back on its own, and the re-auth link pointed the team at the correct fix.

What good looks like

After this runs as a canary check, a 401 is never a mystery. It is either a clean OK, a clearly labeled SCOPE_DRIFT with the exact missing scopes named, or a TOKEN_REVOKED_OR_EXPIRED after a single confirming retry, and in every non-OK case the store_hash stops being retried and a human gets a direct re-auth link instead of a vague error report.

FAQ

Why did my BigCommerce access token suddenly stop working?

BigCommerce invalidates a stored OAuth access token whenever the app's declared scopes change in the app or Developer Portal profile. The token is only actually replaced the next time the merchant reopens the app and re-consents through the auth callback, which returns a fresh access_token and the new scope string. Any script still holding the old token gets a generic 401 Unauthorized on every call afterward, with no distinct error code that says the scopes changed.

How do I tell scope drift apart from a plain revoked or expired token?

Compare the scope string you captured at the last successful OAuth token exchange against the scopes your app currently requires. If the stored token is missing a scope the app now needs, that is scope drift. If the scopes still match but you still get a 401 after one retry, treat it as a plain revocation or expiry. Both look identical as a bare 401, so the comparison is the only reliable way to tell them apart.

Can a script automatically fix a BigCommerce token that went stale from a scope change?

No. A scope change can only be resolved by the merchant reopening the app and re-consenting through the auth callback, so the correct behavior is to flag the store_hash and stop retrying it, not to silently try to mint a new token. The script should log the missing scopes and, if not in dry run, emit the re-auth URL so an admin can send it to the store owner.

Related field notes

Citations

On the problem:

  1. BigCommerce Docs: implementing OAuth and the auth callback flow. docs.bigcommerce.com implementing OAuth
  2. BigCommerce Support Community: OAuth scopes. support.bigcommerce.com OAuth scopes
  3. BigCommerce Support Community: 401 Unauthorized? support.bigcommerce.com 401 unauthorized

On the solution:

  1. BigCommerce Developer Center: single-click app OAuth flow. developer.bigcommerce.com single-click app OAuth flow
  2. BigCommerce Developer Center: API status codes. developer.bigcommerce.com API status codes
  3. BigCommerce Docs: API accounts. docs.bigcommerce.com API accounts

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, webhooks, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this stop a wasted afternoon of debugging?

If this saved you from chasing a phantom network issue or a "revoked" token that was really just scope drift, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all BigCommerce field notes