Diagnostic Admin API and Auth

Refresh token race condition causes random invalid_client 401s

An integration has been refreshing its Shopware Admin API token for weeks without a problem, then out of nowhere a call fails with a 401 and the body says invalid_client. Retrying the exact same refresh a second later works fine. Nothing about the credential changed. What changed is timing: two refresh requests went out with the same cached refresh token at nearly the same instant, and Shopware's OAuth layer only lets one of them win. Here is why that happens and a small client that detects the race and recovers without ever hammering the token endpoint.

Python and Node.js Admin API OAuth Safe by default (dry run)
The short answer

Shopware's Admin API OAuth layer runs on League OAuth2 Server with refresh token rotation turned on: every POST /api/oauth/token call with grant_type=refresh_token immediately revokes the refresh token it was given and hands back a new access and refresh pair. If a script, or several worker processes sharing one stored token, fire concurrent refresh requests with that same refresh token, the server processes one first and revokes it, so the other is checked against an already-revoked token and Shopware answers with a 401 and an invalid_client error. Shopware's own Administration hit this exact bug across multiple browser tabs (GitHub issue #13130), and it has been patched and reopened repeatedly since 6.3.5. The fix for an integration is not to retry the refresh forever. Detect two or more consecutive invalid_client failures in a short window, throw away the cached token pair, and re-authenticate from scratch with grant_type=client_credentials, behind a single-flight lock so only one caller re-authenticates at a time. Full code, tests, and sources are below.

The problem in plain words

A refresh token is meant to be a renewable pass. You trade it in at POST /api/oauth/token with grant_type=refresh_token, and you get a new access token back so you never have to send the client secret again for a while. That works fine as long as exactly one caller redeems a given refresh token at a time.

Shopware's Admin API OAuth server rotates the refresh token on every redemption. The moment a refresh call succeeds, the token that was just presented is revoked, even though it was perfectly valid a moment earlier. That is a reasonable security property on its own. The trouble starts when more than one caller holds the same cached refresh token, for example several worker processes or threads sharing one token store, or a browser tab duplicated into two. If two of them decide to refresh within the same short window, both requests reach the server carrying the identical refresh token. The server can only honor one. It revokes the token for the winner and issues a new pair. The loser's request, which looked identical and was sent only milliseconds apart, is now checked against a token that no longer exists, and the response is a 401 with an invalid_client error, sometimes phrased as "Token has been revoked."

Caller A refresh_token, same token Caller B refresh_token, same token /api/oauth/token League OAuth2 Server rotates on every redemption first, wins 200 OK, new token pair old refresh_token revoked second, loses 401 invalid_client token has been revoked
Both callers held the same refresh token and both were valid a moment before. The server can only honor one redemption, and the other is left holding a revoked token.

Why it happens

The root cause lives inside the OAuth layer's rotation policy, but it shows up in ordinary integration code. A few common ways teams run into it:

This is not a sign of a bad client id or secret. A genuinely bad credential fails on every single call. This race fails intermittently, in bursts, and always right after some other concurrent call's refresh succeeded. See the citations at the end for the exact issues and the Admin API auth documentation.

The key insight

Detection has to live at the transport layer, not by inspecting any Shopware entity. Watch for a 401 whose JSON error body names invalid_client or invalid_grant, or whose message mentions the token being revoked, and count how many of those happened back to back for a given client id within a short window. One such 401 could be a real bad credential. Two or more in a row, right after a sibling call's refresh succeeded, is the race signature. Once you see that signature, stop retrying the refresh grant, since it will race the same revoked token again. Throw the cached pair away and get a fresh one with client_credentials instead.

The fix, as a flow

We never touch the OAuth server's rotation policy, since that is Shopware's and cannot be turned off from the client side. Instead the client watches its own call outcomes, counts consecutive invalid_client 401s, and once the threshold is crossed it discards the cached tokens and performs one full client_credentials re-authentication, guarded by a single in-flight lock so every waiting caller shares that one call instead of each starting its own.

API call returns 401 invalid_client body should_reauth_with _client_credentials pure decision function 2+ consecutive invalid_client? no, one plain retry retry refresh_token once yes, race Log event single-flight lock discard cached pair client_credentials fresh token pair
The pure function only escalates to a full re-authentication once the burst signature is confirmed. A single 401 gets one plain refresh retry, since it might be a genuinely bad credential rather than a race.

Build it step by step

1

Get an Admin API access key

Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Keep the shop URL and credentials in environment variables, never in the file, and start with DRY_RUN on so the client only logs the race events it detects before you wire it into anything that writes.

setup (shell)
pip install requests

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   # start safe, this script only ever reports the race events it sees
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   // start safe, this script only ever reports the race events it sees
2

Authenticate once with client_credentials

The very first token always comes from a full client_credentials grant. Every later call sends Authorization: Bearer <token> and Accept: application/json. Keep the access token, refresh token, client id, and client secret together as one cached pair, since the whole point of this fix is knowing when to throw that pair away.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def authenticate_client_credentials():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    return {"access_token": body["access_token"], "refresh_token": body.get("refresh_token")}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function authenticateClientCredentials() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return { accessToken: body.access_token, refreshToken: body.refresh_token };
}
3

Recognize an invalid_client 401 in the response body

Shopware's error payload follows the OAuth2 error shape, sometimes with an errors array and sometimes a flatter body, and the message can read invalid_client, invalid_grant, or mention that the token was revoked. Pull whatever text is available out of the body into one lowercase string so the decision function has something simple to match against.

step3.py
def error_signature(error_body):
    """Collapse Shopware's error payload into one lowercase string to match against."""
    parts = []
    for err in (error_body or {}).get("errors", []) or []:
        parts.append(str(err.get("code", "")))
        parts.append(str(err.get("title", "")))
        parts.append(str(err.get("detail", "")))
    parts.append(str((error_body or {}).get("error", "")))
    parts.append(str((error_body or {}).get("error_description", "")))
    parts.append(str((error_body or {}).get("message", "")))
    return " ".join(parts).lower()
step3.js
function errorSignature(errorBody) {
  const parts = [];
  for (const err of (errorBody || {}).errors || []) {
    parts.push(String(err.code || ""));
    parts.push(String(err.title || ""));
    parts.push(String(err.detail || ""));
  }
  parts.push(String((errorBody || {}).error || ""));
  parts.push(String((errorBody || {}).error_description || ""));
  parts.push(String((errorBody || {}).message || ""));
  return parts.join(" ").toLowerCase();
}
4

Decide, with one pure function

The decision takes the status code, the parsed error body, and a rolling count of how many consecutive invalid_client failures this client id has just seen, and does no I/O at all. It only says yes once the signature matches and the count has crossed the threshold. Below the threshold, the caller should do one plain refresh_token retry first, since a single 401 could be a genuinely bad credential rather than a race.

decide.py
RACE_MARKERS = ("invalid_client", "invalid_grant", "revoked")

def should_reauth_with_client_credentials(status_code, error_body, consecutive_invalid_client_count, threshold=2):
    if status_code != 401:
        return False
    signature = error_signature(error_body)
    if not any(marker in signature for marker in RACE_MARKERS):
        return False
    return consecutive_invalid_client_count >= threshold
decide.js
const RACE_MARKERS = ["invalid_client", "invalid_grant", "revoked"];

export function shouldReauthWithClientCredentials(statusCode, errorBody, consecutiveInvalidClientCount, threshold = 2) {
  if (statusCode !== 401) return false;
  const signature = errorSignature(errorBody);
  if (!RACE_MARKERS.some((marker) => signature.includes(marker))) return false;
  return consecutiveInvalidClientCount >= threshold;
}
5

Guard the re-auth path with a single-flight lock

Once the decision is true, every caller that hits the race in the same window should await one shared client_credentials call instead of each starting its own. Log the event first, before forcing re-auth, with the timestamp, a caller id, and the consecutive count, so you have an audit trail even in dry run.

apply.py
import threading
import datetime

_lock = threading.Lock()
_inflight_result = None

def log_race_event(caller_id, consecutive_count):
    print(f"{datetime.datetime.utcnow().isoformat()}Z race detected caller={caller_id} consecutive={consecutive_count}")

def reauth_single_flight(caller_id, consecutive_count):
    global _inflight_result
    log_race_event(caller_id, consecutive_count)
    with _lock:
        if _inflight_result is None:
            _inflight_result = authenticate_client_credentials()
        return _inflight_result
apply.js
let inflightPromise = null;

function logRaceEvent(callerId, consecutiveCount) {
  console.warn(`${new Date().toISOString()} race detected caller=${callerId} consecutive=${consecutiveCount}`);
}

function reauthSingleFlight(callerId, consecutiveCount) {
  logRaceEvent(callerId, consecutiveCount);
  if (!inflightPromise) {
    inflightPromise = authenticateClientCredentials().finally(() => {
      inflightPromise = null;
    });
  }
  return inflightPromise;
}
6

Wire it together with a dry run guard

The client wraps every API call. On a 401, it bumps the consecutive count for that client id and asks should_reauth_with_client_credentials what to do. If the answer is false, it does one plain refresh_token retry. If true, it logs the event and, unless DRY_RUN is on, calls the single-flight re-auth and retries the original request with the fresh token. On any successful call the consecutive count resets to zero.

Run it safe

Start with DRY_RUN=true so the client only logs the race events it would have acted on. Never retry grant_type=refresh_token in a loop once you have seen the race signature, since it will keep colliding with the same revoked token. The single-flight lock is what stops a burst of concurrent callers from turning one race into a storm of client_credentials calls.

The full code

Here is the complete client in one file for each language. It authenticates, wraps Admin API calls, recognizes the invalid_client race signature, and re-authenticates behind a single-flight lock instead of retrying the same revoked refresh token.

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

refresh_race_guard.py
"""Detect and recover from Shopware's Admin API refresh token race condition.

Shopware's Admin API OAuth layer (League OAuth2 Server) rotates the refresh
token on every redemption: POST /api/oauth/token with grant_type=refresh_token
revokes the presented refresh token and issues a new pair. When two concurrent
callers refresh the same cached token, one wins and the other gets a 401
invalid_client against the now-revoked token (the same race Shopware's own
Administration hit across browser tabs, GitHub issue #13130).

This client watches for a burst of consecutive invalid_client 401s per
client id. Below the threshold it does one plain refresh_token retry, since a
single 401 could be a genuinely bad credential. At or above the threshold it
discards the cached token pair and re-authenticates from scratch with
client_credentials, behind a single-flight lock so concurrent callers share
one re-auth call. Every detected event is logged for audit. DRY_RUN only logs
what would happen instead of forcing the re-auth call.
"""
import os
import threading
import datetime
import logging
import requests

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

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
RACE_THRESHOLD = int(os.environ.get("RACE_THRESHOLD", "2"))

RACE_MARKERS = ("invalid_client", "invalid_grant", "revoked")

_lock = threading.Lock()
_inflight_result = None
_consecutive_invalid_client = {}


def error_signature(error_body):
    """Collapse Shopware's error payload into one lowercase string to match against."""
    parts = []
    for err in (error_body or {}).get("errors", []) or []:
        parts.append(str(err.get("code", "")))
        parts.append(str(err.get("title", "")))
        parts.append(str(err.get("detail", "")))
    parts.append(str((error_body or {}).get("error", "")))
    parts.append(str((error_body or {}).get("error_description", "")))
    parts.append(str((error_body or {}).get("message", "")))
    return " ".join(parts).lower()


def should_reauth_with_client_credentials(status_code, error_body, consecutive_invalid_client_count, threshold=2):
    if status_code != 401:
        return False
    signature = error_signature(error_body)
    if not any(marker in signature for marker in RACE_MARKERS):
        return False
    return consecutive_invalid_client_count >= threshold


def log_race_event(caller_id, consecutive_count):
    log.warning(
        "%sZ race detected caller=%s consecutive=%d",
        datetime.datetime.utcnow().isoformat(), caller_id, consecutive_count,
    )


def authenticate_client_credentials():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    return {"access_token": body["access_token"], "refresh_token": body.get("refresh_token")}


def refresh_with_token(refresh_token):
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "refresh_token", "refresh_token": refresh_token,
              "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    body = r.json() if r.text else {}
    return r.status_code, body


def reauth_single_flight(caller_id, consecutive_count):
    global _inflight_result
    log_race_event(caller_id, consecutive_count)
    if DRY_RUN:
        log.info("DRY_RUN on, not calling client_credentials for caller=%s", caller_id)
        return None
    with _lock:
        if _inflight_result is None:
            _inflight_result = authenticate_client_credentials()
        return _inflight_result


def call_with_token_recovery(caller_id, tokens, api_call):
    """Call api_call(access_token) -> (status_code, body). Recovers from the
    invalid_client race by retrying once, then re-authenticating if needed."""
    status, body = api_call(tokens["access_token"])
    if status != 401:
        _consecutive_invalid_client[caller_id] = 0
        return status, body, tokens

    count = _consecutive_invalid_client.get(caller_id, 0) + 1
    _consecutive_invalid_client[caller_id] = count

    if not should_reauth_with_client_credentials(status, body, count, RACE_THRESHOLD):
        # Below threshold: try one plain refresh before escalating.
        refresh_status, refresh_body = refresh_with_token(tokens["refresh_token"])
        if refresh_status == 200:
            new_tokens = {"access_token": refresh_body["access_token"],
                          "refresh_token": refresh_body.get("refresh_token", tokens["refresh_token"])}
            _consecutive_invalid_client[caller_id] = 0
            status, body = api_call(new_tokens["access_token"])
            return status, body, new_tokens
        return refresh_status, refresh_body, tokens

    fresh = reauth_single_flight(caller_id, count)
    if fresh is None:
        return status, body, tokens
    _consecutive_invalid_client[caller_id] = 0
    status, body = api_call(fresh["access_token"])
    return status, body, fresh


def run():
    caller_id = os.environ.get("CALLER_ID", "worker-1")
    tokens = authenticate_client_credentials()

    def whoami(access_token):
        r = requests.get(
            f"{SHOPWARE_URL}/api/_info/me",
            headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
            timeout=30,
        )
        return r.status_code, (r.json() if r.text else {})

    status, body, tokens = call_with_token_recovery(caller_id, tokens, whoami)
    log.info("Done. status=%d dry_run=%s", status, DRY_RUN)


if __name__ == "__main__":
    run()
refresh-race-guard.js
/**
 * Detect and recover from Shopware's Admin API refresh token race condition.
 *
 * Shopware's Admin API OAuth layer (League OAuth2 Server) rotates the refresh
 * token on every redemption: POST /api/oauth/token with grant_type=refresh_token
 * revokes the presented refresh token and issues a new pair. When two concurrent
 * callers refresh the same cached token, one wins and the other gets a 401
 * invalid_client against the now-revoked token (the same race Shopware's own
 * Administration hit across browser tabs, GitHub issue #13130).
 *
 * This client watches for a burst of consecutive invalid_client 401s per
 * client id. Below the threshold it does one plain refresh_token retry, since a
 * single 401 could be a genuinely bad credential. At or above the threshold it
 * discards the cached token pair and re-authenticates from scratch with
 * client_credentials, behind a single-flight lock so concurrent callers share
 * one re-auth call. Every detected event is logged for audit. DRY_RUN only logs
 * what would happen instead of forcing the re-auth call.
 *
 * Guide: https://www.allanninal.dev/shopware/refresh-token-race-invalid-client/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.myshopware.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIADUMMYDUMMYDUMMYDUMMY";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const RACE_THRESHOLD = Number(process.env.RACE_THRESHOLD || 2);

const RACE_MARKERS = ["invalid_client", "invalid_grant", "revoked"];

let inflightPromise = null;
const consecutiveInvalidClient = new Map();

export function errorSignature(errorBody) {
  const parts = [];
  for (const err of (errorBody || {}).errors || []) {
    parts.push(String(err.code || ""));
    parts.push(String(err.title || ""));
    parts.push(String(err.detail || ""));
  }
  parts.push(String((errorBody || {}).error || ""));
  parts.push(String((errorBody || {}).error_description || ""));
  parts.push(String((errorBody || {}).message || ""));
  return parts.join(" ").toLowerCase();
}

export function shouldReauthWithClientCredentials(statusCode, errorBody, consecutiveInvalidClientCount, threshold = 2) {
  if (statusCode !== 401) return false;
  const signature = errorSignature(errorBody);
  if (!RACE_MARKERS.some((marker) => signature.includes(marker))) return false;
  return consecutiveInvalidClientCount >= threshold;
}

function logRaceEvent(callerId, consecutiveCount) {
  console.warn(`${new Date().toISOString()} race detected caller=${callerId} consecutive=${consecutiveCount}`);
}

async function authenticateClientCredentials() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return { accessToken: body.access_token, refreshToken: body.refresh_token };
}

async function refreshWithToken(refreshToken) {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  const text = await res.text();
  return { status: res.status, body: text ? JSON.parse(text) : {} };
}

async function reauthSingleFlight(callerId, consecutiveCount) {
  logRaceEvent(callerId, consecutiveCount);
  if (DRY_RUN) {
    console.log(`DRY_RUN on, not calling client_credentials for caller=${callerId}`);
    return null;
  }
  if (!inflightPromise) {
    inflightPromise = authenticateClientCredentials().finally(() => {
      inflightPromise = null;
    });
  }
  return inflightPromise;
}

export async function callWithTokenRecovery(callerId, tokens, apiCall) {
  let { status, body } = await apiCall(tokens.accessToken);
  if (status !== 401) {
    consecutiveInvalidClient.set(callerId, 0);
    return { status, body, tokens };
  }

  const count = (consecutiveInvalidClient.get(callerId) || 0) + 1;
  consecutiveInvalidClient.set(callerId, count);

  if (!shouldReauthWithClientCredentials(status, body, count, RACE_THRESHOLD)) {
    const refreshed = await refreshWithToken(tokens.refreshToken);
    if (refreshed.status === 200) {
      const newTokens = { accessToken: refreshed.body.access_token, refreshToken: refreshed.body.refresh_token || tokens.refreshToken };
      consecutiveInvalidClient.set(callerId, 0);
      const retried = await apiCall(newTokens.accessToken);
      return { status: retried.status, body: retried.body, tokens: newTokens };
    }
    return { status: refreshed.status, body: refreshed.body, tokens };
  }

  const fresh = await reauthSingleFlight(callerId, count);
  if (!fresh) return { status, body, tokens };
  consecutiveInvalidClient.set(callerId, 0);
  const retried = await apiCall(fresh.accessToken);
  return { status: retried.status, body: retried.body, tokens: fresh };
}

async function whoami(accessToken) {
  const res = await fetch(`${SHOPWARE_URL}/api/_info/me`, {
    headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
  });
  const text = await res.text();
  return { status: res.status, body: text ? JSON.parse(text) : {} };
}

export async function run() {
  const callerId = process.env.CALLER_ID || "worker-1";
  const tokens = await authenticateClientCredentials();
  const result = await callWithTokenRecovery(callerId, tokens, whoami);
  console.log(`Done. status=${result.status} dry_run=${DRY_RUN}`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides whether the client burns a full re-authentication or just retries once. Because should_reauth_with_client_credentials is pure, no network and no Shopware credentials are needed. It just feeds in plain status codes and error bodies and checks the answer.

test_refresh_race.py
from refresh_race_guard import should_reauth_with_client_credentials


def invalid_client_body():
    return {"errors": [{"code": "invalid_client", "title": "invalid_client", "detail": "Token has been revoked"}]}


def other_error_body():
    return {"errors": [{"code": "EXPIRED", "title": "Unauthorized", "detail": "Some other reason"}]}


def test_reauth_when_threshold_reached():
    assert should_reauth_with_client_credentials(401, invalid_client_body(), 2) is True


def test_no_reauth_below_threshold():
    assert should_reauth_with_client_credentials(401, invalid_client_body(), 1) is False


def test_no_reauth_when_not_401():
    assert should_reauth_with_client_credentials(500, invalid_client_body(), 5) is False


def test_no_reauth_when_error_does_not_match():
    assert should_reauth_with_client_credentials(401, other_error_body(), 5) is False


def test_custom_threshold_respected():
    assert should_reauth_with_client_credentials(401, invalid_client_body(), 3, threshold=3) is True
    assert should_reauth_with_client_credentials(401, invalid_client_body(), 2, threshold=3) is False


def test_invalid_grant_message_matches():
    body = {"error": "invalid_grant", "error_description": "The refresh token is invalid."}
    assert should_reauth_with_client_credentials(401, body, 2) is True
refresh-race.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { shouldReauthWithClientCredentials } from "./refresh-race-guard.js";

const invalidClientBody = () => ({ errors: [{ code: "invalid_client", title: "invalid_client", detail: "Token has been revoked" }] });
const otherErrorBody = () => ({ errors: [{ code: "EXPIRED", title: "Unauthorized", detail: "Some other reason" }] });

test("reauth when threshold reached", () => {
  assert.equal(shouldReauthWithClientCredentials(401, invalidClientBody(), 2), true);
});

test("no reauth below threshold", () => {
  assert.equal(shouldReauthWithClientCredentials(401, invalidClientBody(), 1), false);
});

test("no reauth when not 401", () => {
  assert.equal(shouldReauthWithClientCredentials(500, invalidClientBody(), 5), false);
});

test("no reauth when error does not match", () => {
  assert.equal(shouldReauthWithClientCredentials(401, otherErrorBody(), 5), false);
});

test("custom threshold respected", () => {
  assert.equal(shouldReauthWithClientCredentials(401, invalidClientBody(), 3, 3), true);
  assert.equal(shouldReauthWithClientCredentials(401, invalidClientBody(), 2, 3), false);
});

test("invalid_grant message matches", () => {
  const body = { error: "invalid_grant", error_description: "The refresh token is invalid." };
  assert.equal(shouldReauthWithClientCredentials(401, body, 2), true);
});

Case studies

Worker pool

Five workers, one cached token, random failures

A fulfillment sync ran as five parallel worker processes, all reading the same access and refresh token pair from a shared database row. Every few hours, one or two workers would fail with a 401 invalid_client for no visible reason, and restarting the worker fixed it until the next time. The team assumed it was a flaky network and added blind retries, which only made the races more frequent since every retry was another chance for two workers to refresh at once.

Once they instrumented the 401 body and started counting consecutive invalid_client failures per worker, the burst pattern was obvious: two workers always failed within the same second, right after a third worker's refresh had succeeded. Adding the single-flight re-auth guard made the failures disappear entirely, since only one worker ever calls client_credentials at a time now.

Scheduled job

An hourly cron that occasionally doubled up

A pricing export ran on a cron schedule, but a slow previous run occasionally overlapped with the next scheduled trigger. Both instances loaded the same cached refresh token from disk and both tried to refresh within milliseconds of each other, and the loser crashed the whole job with an unhandled 401.

The fix was not to prevent overlap, since that required changing the scheduler. It was to make the token client resilient to it: detect the invalid_client burst, log it, and re-authenticate cleanly instead of crashing. Now an overlapping run just logs one race event and keeps going with a fresh token.

What good looks like

After this is wired in, a race between concurrent refresh calls produces one logged event and a clean recovery instead of a crash or a silent authentication outage. The client never retries a refresh grant it knows will race the same revoked token, and it never lets a burst of concurrent callers turn into a burst of client_credentials calls, since the single-flight lock keeps that to exactly one. A single genuinely bad credential still fails clearly, because the threshold guard only escalates on a repeated pattern, not a single 401.

FAQ

Why does Shopware randomly return 401 invalid_client on a refresh token that worked a moment ago?

Shopware's Admin API OAuth layer runs on League OAuth2 Server with refresh token rotation. Every POST /api/oauth/token call with grant_type=refresh_token immediately revokes the presented refresh token and issues a brand new access and refresh pair. If two requests fire concurrently with the same cached refresh token, the first one wins and revokes it, so the second is validated against an already-revoked token and gets a 401 invalid_client, even though the token was valid microseconds earlier.

Is this the same bug as the random logouts in the Shopware Administration?

Yes, it is the same underlying race. GitHub issue #13130 describes Shopware's own Administration hitting it across multiple open browser tabs, and it has been patched and reopened multiple times since Shopware 6.3.5 under NEXT-10663, NEXT-7947, NEXT-25484, and NEXT-39517/NEXT-39518. Any custom integration that caches one refresh token and refreshes it from more than one concurrent process or thread hits the identical race, because the fix was scoped to the Administration's own token handling, not to the OAuth server's rotation behavior itself.

Should I just retry the refresh_token grant again when I see invalid_client?

Not immediately. A single 401 could be a genuinely bad or expired credential, so the safe pattern is one plain refresh_token retry first. Only after two or more consecutive invalid_client failures within a short window should the client discard the cached token pair entirely and re-authenticate with a full client_credentials grant, guarded by a single-flight lock so concurrent callers share one re-auth call instead of each hammering POST /api/oauth/token.

Related field notes

Citations

On the problem:

  1. Random Logouts in Administration Due to Refresh Token Race Condition. GitHub Issue #13130, shopware/shopware. github.com/shopware/shopware/issues/13130
  2. auto token refresh fails when tab is heavily throttled by chrome, causing logout. GitHub Issue #3572, shopware/shopware. github.com/shopware/shopware/issues/3572
  3. Implement proper refresh token handling. GitHub Issue #11203, shopware/shopware. github.com/shopware/shopware/issues/11203

On the solution:

  1. Shopware Developer Documentation: Authentication and API Requests. developer.shopware.com/docs/guides/development/integrations-api/auth-api-requests.html
  2. Shopware Developer Documentation: APIs overview. developer.shopware.com/docs/guides/development/integrations-api/
  3. Shopware Admin API Reference. developer.shopware.com/resources/api/admin-api-reference.html

Stuck on a tricky one?

If you have a problem in Shopware's Admin API, authentication, integrations, or the message queue 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 your random 401s?

If this saved you from chasing a phantom invalid_client bug, 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 Shopware field notes