FA©26
19 July 2026·35 min readShopifyOAuthpublic appstoken rotationPostgres

Shopify's Expiring Offline Tokens: A Concurrency-Safe Migration for Public Apps Before January 1, 2027

Why did every worker decide to refresh the same Shopify token?

Because “check expiry, then refresh” is two operations, and production inserts other work between them.

A public Shopify app can have background jobs, webhook handlers, user-driven requests, cron processes, queue workers, and reconciliation tasks calling the Admin API for the same shop. They may run in separate containers or regions. Each reads the same token row. Each sees that the access token is near expiry. Each concludes that it should refresh.

Shopify's current expiring offline token model allows one refreshable expiring offline token per app and shop. A refresh returns a new access token and a new refresh token. The previous access token remains valid until its stated expires_in duration ends, but the previous refresh token is invalidated after use. Shopify documents these details in About offline access tokens.

That combination is designed to let in-flight API requests finish while the application moves to the new pair. It is not permission for multiple processes to rotate independently.

Here is the naive code:

async function getShopifyToken(shopId: string): Promise<string> {
  const session = await sessions.findByShopId(shopId);

  if (session.expiresAt <= new Date()) {
    const refreshed = await refreshShopifyToken(session.refreshToken);
    await sessions.update(shopId, refreshed);
    return refreshed.accessToken;
  }

  return session.accessToken;
}

Two callers can read the same expired row before either update commits. Both send the same refresh token. The first refresh establishes the new pair. The second request may be covered by Shopify's retry behavior if it is treated as a retry of the same refresh request, but your application should not depend on uncontrolled concurrent calls being interpreted as intentional recovery. It also does not solve the database race. One process can write stale or partial state over the row another process just updated.

The function needs a per-shop serialization boundary. In Postgres, that can be a row lock or an advisory lock. The process acquires the lock, rereads the row inside the lock, and only refreshes if the token is still near expiry. Every waiting process then sees the new pair and returns it.

await db.transaction(async (tx) => {
  await tx.query(
    "select pg_advisory_xact_lock(hashtextextended($1, 0))",
    [`shopify-token:${shopId}`],
  );

  const current = await tx.one(
    `select * from shopify_sessions where shop_id = $1 for update`,
    [shopId],
  );

  if (!needsRefresh(current)) {
    return current.access_token;
  }

  const rotated = await shopifyRefresh(current.refresh_token);
  await persistRotation(tx, current, rotated);
  return rotated.accessToken;
});

That sample still needs careful treatment of network calls inside transactions, which I address later. The important point is the second read. A lock without a second expiry check only turns parallel refreshes into sequential duplicate refreshes.

[DIAGRAM] Draw five workers requesting a token for one shop. Show all five entering a per-shop lock, one worker performing the refresh, and the other four rereading the updated row and reusing the new access token.

The first rule is simple: token rotation is shared mutable state. Treat it like inventory allocation, not a helper function.

What exactly changes for Shopify public apps on January 1, 2027?

Shopify announced on May 20, 2026 that all public apps must use expiring offline access tokens for Admin API requests starting January 1, 2027. Public apps created on or after April 1, 2026 already fall under the expiring-token requirement. Public apps created before that date must migrate existing non-expiring offline tokens. After January 1, 2027, a public app using non-expiring tokens for REST or GraphQL Admin API requests receives authentication errors. Shopify's official notice is Expiring offline access tokens required for all public apps as of January 1, 2027.

The requirement does not apply to custom apps or apps created by merchants in the Dev Dashboard or admin, according to the current documentation. You still need to classify every installation correctly. An app portfolio may include public apps, custom integrations, development stores, internal tools, and legacy credentials. Do not run one migration query against every row called shopify_session.

Expiring offline tokens have two relevant lifetimes in Shopify's current response examples. The access token returns expires_in: 3600. The refresh token returns refresh_token_expires_in: 7776000, which is 90 days. Shopify's changelog describes the shorter credential window as a security improvement because a leaked access token does not remain valid indefinitely.

Refresh rotates both values. Shopify returns a new access token and a new refresh token. The new refresh token receives a new 90-day lifetime from the refresh time. The previous access token remains valid until its original access-token expiry. The previous refresh token cannot extend the old generation after successful use.

For existing non-expiring offline tokens, migration uses token exchange. The app posts the old token as the subject_token, requests an offline access token, and sets expiring=1. On successful exchange, Shopify revokes the original non-expiring token. Shopify labels the migration one-time and irreversible per shop. Merchants do not need to reinstall when the application performs the exchange correctly.

The current migration requires four application changes before the first exchange:

  1. Session storage must hold access-token expiry, refresh token, and refresh-token expiry.

  2. New installs must request expiring offline tokens.

  3. every Admin API caller must obtain a current token through one shared token service.

  4. Existing shops must be exchanged and recorded safely.

The deadline is not the most difficult part. The architecture change is. A non-expiring token could be loaded once and cached for a long time. An expiring token makes credential state active. It changes while the app is running.

I model the shop credential as generations:

interface ShopifyOfflineCredential {
  shopId: string;
  generation: number;
  accessTokenCiphertext: string;
  accessTokenExpiresAt: Date;
  refreshTokenCiphertext: string;
  refreshTokenExpiresAt: Date;
  scopes: string[];
  status: "active" | "refreshing" | "reauth_required" | "revoked";
  updatedAt: Date;
}

The generation number gives logs and workers a clean way to say which credential they used. “The token failed” is vague. “Generation 19 received a 401 after generation 20 was committed” tells you the caller used stale state.

You are not just adding a refresh token. You are replacing a static secret with a rotating distributed credential.

What does a failed token migration cost beyond a few 401s?

Authentication failures are the visible symptom. The business failures happen behind them.

A Shopify public app often performs work without a merchant actively using the UI. It imports orders, updates products, reconciles inventory, generates feeds, processes webhooks, syncs fulfillment, or calculates reports. An expired or lost refresh token can stop those jobs while the merchant assumes the app is still operating.

Consider a multichannel inventory app. A Shopify order webhook arrives, the handler needs the Admin API to read fulfillment and line-item details, and the token service fails to rotate. The handler returns an error. Shopify retries the webhook. Meanwhile, a scheduled reconciliation job also fails. The external marketplace still shows stock. The next order oversells. The token bug becomes an inventory and customer-service incident.

A failed migration can create several cost layers.

The first is lost background work. Jobs continue to enqueue but cannot call Shopify. A queue may grow quietly until storage, retry limits, or worker concurrency becomes the next failure.

The second is duplicated work. Operators replay failed jobs after credentials recover. If the jobs are not idempotent, orders, fulfillment updates, product mutations, or notifications repeat.

The third is merchant churn. The merchant does not experience “OAuth token rotation.” They experience missing orders, stale stock, or an app that asks them to reopen it after weeks of silent failure.

The fourth is support burden. A 401 can mean an expired access token, invalid refresh token, app uninstall, scope change, wrong shop domain, stale cache, revoked credential, or database rollback. Without token-generation logs, every case looks similar.

The fifth is migration lockout. The exchange from a non-expiring token is irreversible when successful. If Shopify accepts the exchange but your application fails before persisting the new pair, the old token is revoked and the new pair may be lost. This is the highest-risk point in the rollout.

The sixth is security exposure. Teams under deadline pressure may log tokens, copy them into spreadsheets, keep old plaintext columns “temporarily,” or grant wide database access to troubleshoot. A security migration can make credential handling worse if the operational design is rushed.

I track token incidents by outcome:

{
  "shop_id": "[INTERNAL SHOP ID]",
  "credential_generation": 21,
  "operation": "orders.reconcile",
  "auth_outcome": "refresh_failed",
  "shopify_status": 401,
  "shopify_error": "[REDACTED ERROR CODE]",
  "job_state": "paused",
  "business_effect": "[ORDERS DELAYED, INVENTORY STALE, NONE, UNKNOWN]",
  "recovery": "[RETRY, RELAUNCH REQUIRED, UNINSTALL DETECTED]",
  "correlation_id": "[UUID]"
}

[REAL NUMBER] Insert the actual number of shops by token mode, failed refreshes, reauthentication-required shops, delayed jobs, queue depth, merchant tickets, and recovery time. Mark any missing business impact as unverified.

A few 401s can be harmless during a controlled test. A public app that cannot distinguish and recover from them has a product reliability problem.

Why does refresh-if-expired fail under real concurrency?

Because expiry is not a single moment observed by every process at once.

One worker reads from the primary. Another reads from a replica with delay. A serverless function starts with a cached session. A long-running job loaded the access token twenty minutes earlier. A webhook handler begins just before rotation and sends its API request just after. All of them can hold different views of the same shop credential.

A strict check such as expires_at <= now() is also too late. The token may be valid when checked but expire before the Admin API request reaches Shopify. Refresh with a safety window. The window should cover normal request latency, queue delay, and clock uncertainty. Do not hardcode a universal number in an article. Measure the application's behavior and choose a documented margin.

function shouldRefresh(
  expiresAt: Date,
  now: Date,
  safetyWindowMs: number,
): boolean {
  return expiresAt.getTime() - now.getTime() <= safetyWindowMs;
}

Caching makes it worse. If the app stores credentials in Redis for speed, the database update must invalidate or version the cache. Time-based cache expiry alone can keep a retired refresh token in circulation. Cache entries should include the generation, and writes should use compare-and-set behavior where possible.

{
  "shop_id": "shop_123",
  "generation": 22,
  "access_token": "[ENCRYPTED OR SECRET REFERENCE]",
  "access_token_expires_at": "[TIMESTAMP]",
  "refresh_token_expires_at": "[TIMESTAMP]"
}

A caller that reads generation 21 after generation 22 exists can still use the old access token until its expiry, based on Shopify's documented retired-access-token behavior. It must not attempt to refresh with generation 21's refresh token.

That is why I separate token use from token refresh. API callers can receive an access token and generation. Only the token service receives the refresh token. Workers never cache refresh tokens locally.

Another failure is the stale write. Worker A refreshes and writes generation 22. Worker B, which loaded generation 21 earlier, receives a delayed response and writes its result as generation 22 without checking the source generation. It can overwrite a newer row. Every rotation update should include an optimistic condition:

update shopify_credentials
set
  generation = generation + 1,
  access_token_ciphertext = $1,
  access_token_expires_at = $2,
  refresh_token_ciphertext = $3,
  refresh_token_expires_at = $4,
  status = 'active',
  updated_at = now()
where shop_id = $5
  and generation = $6
returning generation;

If no row updates, another process changed the credential. Do not overwrite it. Reread and reconcile.

Then there is the thundering herd. At the top of the hour, many jobs for many shops may see tokens near expiry. A global lock serializes unrelated merchants and creates a bottleneck. Lock by app and shop. Add jitter to proactive refresh scheduling so thousands of installations do not rotate in the same second.

[DIAGRAM] Draw primary database, read replica, Redis cache, API workers, and a dedicated token service. Mark which components may read access tokens, and show that only the token service can decrypt or use refresh tokens.

The mechanism is not complicated once visible. The same credential is being read and changed by a distributed system. Your code must admit that fact.

What should the token table store?

Store enough to decide, rotate, audit, recover, and reauthorize without exposing secrets to every service.

The minimum Shopify fields are the current access token, its expiry, the current refresh token, its expiry, and scopes. Production needs more context: generation, token mode, migration state, status, last successful refresh, last definitive auth failure, install identifier, app identifier, shop domain, encryption metadata, and row version.

A practical schema looks like this:

create table shopify_credentials (
  app_id uuid not null,
  shop_id uuid not null,
  shop_domain text not null,
  token_mode text not null check (token_mode in (
    'non_expiring_offline',
    'expiring_offline'
  )),
  status text not null check (status in (
    'active',
    'migration_pending',
    'refreshing',
    'reauth_required',
    'uninstalled',
    'revoked'
  )),
  generation bigint not null default 0,
  access_token_ciphertext text not null,
  access_token_expires_at timestamptz,
  refresh_token_ciphertext text,
  refresh_token_expires_at timestamptz,
  granted_scopes text[] not null default '{}',
  encryption_key_version integer not null,
  migrated_at timestamptz,
  last_refresh_started_at timestamptz,
  last_refresh_succeeded_at timestamptz,
  last_auth_error_at timestamptz,
  last_auth_error_code text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  primary key (app_id, shop_id),
  unique (app_id, shop_domain)
);

Do not store only expires_in. That is a duration relative to the response. Convert it to an absolute timestamp at receipt using the application clock, while keeping the raw response metadata in a secure audit log if useful. The same applies to refresh_token_expires_in.

Record the receipt timestamp and a safety adjustment. If the HTTP response takes several seconds, setting expires_at = now_after_parse + expires_in slightly overstates validity. A stricter calculation starts from the request or response-received timestamp and subtracts a safety margin.

const receivedAt = new Date();
const accessExpiresAt = new Date(
  receivedAt.getTime() + response.expires_in * 1000 - safetyMarginMs,
);

Encrypt tokens at the application layer or store references to a managed secrets system. Database disk encryption is not enough when many application roles can select the plaintext column. The token service should have decryption permission. Analytics, support dashboards, and ordinary workers should not.

Keep migration state separate from auth status. A shop can be migration_pending with a valid non-expiring token. It can be expiring_offline and reauth_required because the refresh token expired. Combining those into one boolean produces impossible states.

Store scope snapshots. A token can refresh correctly while the app lacks a newly required scope. Authentication success does not guarantee authorization for the operation. Compare granted scopes with the app's required capability set and route scope gaps to merchant action.

Use a generation and optimistic version. A timestamp alone is a poor concurrency primitive because database precision, replication, and application clocks can complicate comparisons.

Keep an append-only credential event log without plaintext tokens:

create table shopify_credential_events (
  event_id uuid primary key,
  app_id uuid not null,
  shop_id uuid not null,
  event_type text not null,
  from_generation bigint,
  to_generation bigint,
  request_id text,
  shopify_status integer,
  error_code text,
  created_at timestamptz not null default now(),
  metadata jsonb not null default '{}'
);

A token row is not just credential storage. It is the state machine for whether your app can still operate for that merchant.

How do you refresh once when fifty jobs wake up together?

Use single-flight refresh per app and shop, with a double-check after acquiring the lock.

There are several valid implementations. A Postgres advisory lock works well when Postgres is the shared source of truth. A Redis lock can work when implemented with ownership tokens and safe expiry, but it adds another failure mode. A queue partitioned by shop can serialize credential operations, but API requests still need a path when no queued refresh exists.

I prefer a small token service backed by Postgres. Callers request an access token. The service reads the current row. If the token is comfortably valid, it returns it. If refresh is needed, it attempts to become the refresher.

Avoid holding a normal database transaction open across a slow network request when possible. One pattern uses a short transaction to mark a refresh lease, performs the HTTP call, then uses another transaction to commit the result if the lease and generation still match.

update shopify_credentials
set
  status = 'refreshing',
  refresh_lease_id = $1,
  refresh_lease_expires_at = now() + $2::interval,
  last_refresh_started_at = now(),
  updated_at = now()
where app_id = $3
  and shop_id = $4
  and generation = $5
  and (
    status <> 'refreshing'
    or refresh_lease_expires_at < now()
  )
returning *;

The refresher decrypts the current refresh token, sends the grant request, and then commits the new generation only if it still owns the lease.

update shopify_credentials
set
  generation = generation + 1,
  token_mode = 'expiring_offline',
  status = 'active',
  access_token_ciphertext = $1,
  access_token_expires_at = $2,
  refresh_token_ciphertext = $3,
  refresh_token_expires_at = $4,
  refresh_lease_id = null,
  refresh_lease_expires_at = null,
  last_refresh_succeeded_at = now(),
  updated_at = now()
where app_id = $5
  and shop_id = $6
  and generation = $7
  and refresh_lease_id = $8
returning generation;

Other callers see refreshing. They should wait briefly, subscribe to a completion event, or poll the row with bounded backoff. They should not begin another refresh unless the lease expires and the current credential still needs it.

The lease duration needs care. Too short, and a slow but valid Shopify response causes another process to take over. Too long, and a crashed refresher blocks the shop. Record the actual refresh latency distribution and choose a margin. Do not guess from a local test.

A simpler row-lock transaction can be acceptable at smaller scale, particularly if refresh calls are fast and the database is sized appropriately. The point is not one mandated pattern. The invariants are:

  1. Only one process may rotate a shop generation at a time.

  2. A waiting process must reread after the winner commits.

  3. A stale process may not overwrite a newer generation.

  4. A crashed refresher must be recoverable.

  5. The refresh token must not spread to ordinary workers.

async function getValidAccessToken(input: {
  appId: string;
  shopId: string;
}): Promise<{ token: string; generation: number; expiresAt: Date }> {
  for (let attempt = 0; attempt < MAX_TOKEN_WAIT_ATTEMPTS; attempt += 1) {
    const credential = await credentials.read(input);

    if (credential.status === "reauth_required") {
      throw new ReauthorizationRequiredError(input.shopId);
    }

    if (isComfortablyValid(credential)) {
      return decryptAccessCredential(credential);
    }

    const lease = await credentials.tryAcquireRefreshLease(credential);
    if (lease) {
      return rotateAndCommit(lease);
    }

    await sleep(withJitter(TOKEN_WAIT_BASE_MS, attempt));
  }

  throw new TokenRefreshBusyError(input.shopId);
}

[REAL NUMBER] Insert actual concurrent callers per busy shop, refresh latency, wait duration, lease takeovers, stale-write conflicts, and token-service error rate. Label unmeasured values as unverified.

Fifty callers should create one rotation and forty-nine reads of the winner's state. Anything else is a design bug.

How do you migrate an existing non-expiring token without stranding the shop?

Treat token exchange as an irreversible write with an uncertain network boundary.

Shopify's migration request sends the existing non-expiring offline access token as the subject token and asks for an expiring offline token. On successful exchange, the original non-expiring token is revoked. The response contains the new expiring access token, refresh token, and expiry durations.

The dangerous sequence is:

  1. Shopify completes the exchange.

  2. The response reaches your process.

  3. The process crashes before the database commit.

  4. The old token is no longer usable.

  5. The new token pair is not stored.

You cannot make the external exchange and local database update one ACID transaction. You need a recovery protocol.

Start by finishing the application changes before exchanging any shop. Every Admin API caller must support expiring tokens. New token columns must exist. The token service must be deployed. Refresh logic must be tested. Observability must be live. New installations should already use expiring tokens.

Then migrate shops in controlled cohorts. Before each exchange, create a migration-attempt record with a unique request ID, source generation, encrypted old-token fingerprint, start time, and status. Do not store a second plaintext copy.

create table shopify_token_migrations (
  migration_id uuid primary key,
  app_id uuid not null,
  shop_id uuid not null,
  source_generation bigint not null,
  status text not null check (status in (
    'planned', 'request_started', 'response_received',
    'committed', 'recovery_required', 'failed'
  )),
  request_started_at timestamptz,
  response_received_at timestamptz,
  committed_at timestamptz,
  response_fingerprint text,
  error_code text,
  created_at timestamptz not null default now()
);

Acquire the per-shop migration lock. Reread the credential. Confirm it is still non_expiring_offline, active, and at the expected generation. Send the exchange. As soon as the response arrives, encrypt and persist the entire new pair atomically with token_mode = 'expiring_offline', absolute expiries, and a new generation. Mark the migration committed in the same database transaction.

Do not “test” the new token before saving it. The process can die during the test. Save first, then verify with a low-risk Admin API call. If verification fails, keep the saved credential and record the exact error.

const exchange = await exchangeLegacyToken(legacyToken);

await db.transaction(async (tx) => {
  const updated = await tx.query(
    `update shopify_credentials
     set token_mode = 'expiring_offline',
         generation = generation + 1,
         access_token_ciphertext = $1,
         access_token_expires_at = $2,
         refresh_token_ciphertext = $3,
         refresh_token_expires_at = $4,
         migrated_at = now(),
         status = 'active'
     where app_id = $5
       and shop_id = $6
       and generation = $7
       and token_mode = 'non_expiring_offline'
     returning generation`,
    [
      encrypt(exchange.access_token),
      toExpiry(exchange.expires_in),
      encrypt(exchange.refresh_token),
      toExpiry(exchange.refresh_token_expires_in),
      appId,
      shopId,
      sourceGeneration,
    ],
  );

  if (updated.rowCount !== 1) {
    throw new Error("MIGRATION_STATE_CONFLICT");
  }

  await tx.query(
    `update shopify_token_migrations
     set status = 'committed', committed_at = now()
     where migration_id = $1`,
    [migrationId],
  );
});

The response-loss case during initial legacy exchange needs special planning. Shopify's documented one-hour repeat behavior is stated for refreshing expiring offline tokens with the same refresh token. Do not assume it applies identically to legacy token exchange unless Shopify documents it for that request. If the exchange outcome is uncertain and the old token no longer works, mark the shop for recovery and require merchant relaunch or official support guidance rather than inventing a retry guarantee.

[WORKFLOW JSON] Use the fenced placeholder below, not this line.

// [WORKFLOW JSON: n8n migration coordinator that selects a small shop cohort, calls a protected token-exchange service, records migration state, verifies a safe Admin API query, and pauses the cohort when error thresholds are exceeded. This proves rollout is controlled and recoverable.]

The exchange is one request. The migration is the state machine around it.

What do you do when Shopify refreshed the token but your app lost the response?

This is where Shopify's documented retry behavior matters.

For expiring offline token refresh, Shopify states that the prior refresh token is one-time use, but if the application does not receive the response because of a network interruption or transient Shopify-side outage, it should retry the same request with the same refresh token. Shopify says it returns the same refreshed token response for up to one hour after the original refresh. The documentation tells developers to treat network errors, timeouts, 5xx responses, and 429 responses as transient and continue retrying with the same refresh token.

That recovery window solves a critical distributed-systems problem. Shopify may have committed the rotation even though your process does not know the new pair. Reusing the same refresh token during the documented window can retrieve the same result instead of creating an unrecoverable gap.

Your retry code must preserve the exact source refresh token for the attempt. Do not reread a potentially changed row and substitute another value. The attempt record should identify the credential generation and securely retain access to the source token until the outcome is committed or declared unrecoverable.

async function refreshWithRecovery(
  credential: Credential,
  attempt: RefreshAttempt,
): Promise<RefreshResponse> {
  const sourceRefreshToken = decrypt(credential.refreshTokenCiphertext);

  for (let retry = 0; retry < MAX_REFRESH_RETRIES; retry += 1) {
    try {
      return await shopify.refreshOfflineToken(sourceRefreshToken);
    } catch (error) {
      if (isDefinitiveInvalidRefreshToken(error)) {
        throw new DefinitiveRefreshTokenError(error);
      }

      if (!isTransientRefreshError(error)) {
        throw error;
      }

      await recordTransientFailure(attempt, error, retry);
      await sleep(refreshBackoffWithJitter(retry));
    }
  }

  throw new RefreshOutcomeUnknownError(attempt.id);
}

Shopify identifies a definitive invalid refresh token as a 401 with error: "invalid_request" and the description This request requires an active refresh_token, or a refresh token that has reached its 90-day expiry. In that state, continuing to retry the same token is not recovery. The shop needs token acquisition again, which Shopify says occurs when the merchant relaunches the app.

Do not classify every 401 as definitive refresh-token loss. The 401 from an Admin API request may simply mean the access token expired and should trigger the serialized refresh path. The 401 from the refresh endpoint with the documented error has different meaning.

The one-hour recovery behavior also changes timeout policy. A job does not need to hammer the endpoint. It can back off while remaining inside the documented window. Track elapsed time from the first refresh attempt, not from the latest worker restart.

A process crash should not erase the attempt. Another worker can read the attempt record, confirm the lease is abandoned, load the encrypted source refresh token from the credential generation, and resume the same request. If your credential row has already been overwritten without keeping attempt context, recovery becomes guesswork.

{
  "refresh_attempt_id": "[UUID]",
  "shop_id": "[ID]",
  "source_generation": 41,
  "first_request_at": "[TIMESTAMP]",
  "last_attempt_at": "[TIMESTAMP]",
  "attempt_count": "[ACTUAL]",
  "last_outcome": "timeout",
  "recovery_deadline": "[DERIVED FROM DOCUMENTED WINDOW]",
  "status": "outcome_unknown"
}

The correct response to uncertainty is not “try a new thing.” It is to preserve enough state to repeat the same thing safely.

How should background jobs and webhooks obtain a valid token?

They should not implement refresh logic independently.

Every caller uses one token-service contract. The caller identifies the app and shop, requests a token for a required scope set, and receives a short-lived access token plus generation and expiry. The service decides whether to return, refresh, or require merchant action.

interface TokenRequest {
  appId: string;
  shopId: string;
  requiredScopes: string[];
  minimumValidityMs: number;
  correlationId: string;
}

interface TokenResult {
  accessToken: string;
  generation: number;
  expiresAt: string;
  grantedScopes: string[];
}

The caller never sees the refresh token. It also does not update token rows after a 401. It reports the failure with generation, endpoint, and correlation ID. The token service can decide whether the credential is stale, expired, revoked, or invalid.

Webhook handlers should acknowledge receipt quickly after verifying the webhook and durably storing the event. They should not block webhook acknowledgement on a token refresh plus a long Admin API operation. Store the payload in an inbox, deduplicate on the webhook event or business key, and let a worker process it.

create table shopify_webhook_inbox (
  webhook_id text primary key,
  shop_id uuid not null,
  topic text not null,
  payload jsonb not null,
  received_at timestamptz not null default now(),
  status text not null default 'pending',
  attempt_count integer not null default 0,
  last_error text
);

A worker obtains a valid token, processes the event, and marks the inbox row completed. If the token service returns reauth_required, the event remains pending or moves to a blocked state. It is not discarded because authentication failed at that moment.

Scheduled jobs need the same pattern. Do not load tokens for a thousand shops at job-start time and then process for hours. Obtain a token near each shop's API call, with enough minimum validity for the batch. If one shop is blocked, continue other shops. Partition failures by tenant.

Long jobs should be resumable. Suppose a product export processes pages of GraphQL results. Store the cursor and source snapshot. If the access token expires between pages, obtain a newer generation and continue. Do not restart page one unless the operation is designed to deduplicate output.

Use generation in logs:

{
  "correlation_id": "[UUID]",
  "job_id": "[UUID]",
  "shop_id": "[ID]",
  "credential_generation": 33,
  "graphql_operation": "InventorySnapshot",
  "page_cursor": "[REDACTED]",
  "status": "completed"
}

If an Admin API request returns 401, the caller can make one controlled request for a fresh token and retry the API operation when that operation is safe to retry. Mutation retries require operation-specific idempotency or state verification. Do not automatically replay every GraphQL mutation because authentication changed.

// [WORKFLOW JSON: n8n Shopify webhook flow that verifies the webhook, writes to a Supabase inbox, returns an immediate acknowledgement, then processes through a worker that requests a scoped token and records credential generation. This proves webhook delivery is separated from Admin API availability.]

[DIAGRAM] Draw Shopify webhooks and scheduled jobs feeding a durable inbox and queue, with all Admin API calls passing through one token service. Show one shop blocked for reauthorization while other shops continue.

A token failure should pause one merchant's work. It should not jam the whole application.

How do you handle seasonal shops and expired refresh tokens?

You accept that some shops will require merchant interaction again.

Shopify's current documentation says the refresh token expires after 90 days. Each successful refresh issues a new refresh token with a new 90-day expiration. If the refresh token expires, the app user needs to relaunch the app so the token acquisition flow runs.

An active shop with daily background work will usually rotate before 90 days because the app needs access tokens. A seasonal shop can go quiet. If no request triggers refresh for more than the refresh-token lifetime, the credential expires even though the merchant still considers the app installed.

You have three choices.

The first is proactive refresh. Schedule a low-frequency credential-maintenance job that refreshes before the refresh token expires, even when no business job needs the Admin API. This keeps background access alive, but it creates ongoing credential activity for dormant shops. Confirm that this fits the product's purpose, privacy commitments, and Shopify's current terms. Do not perform unnecessary merchant-data reads just to rotate; the refresh request itself is the credential operation.

The second is lazy recovery. Let dormant credentials expire, mark the shop reauth_required, and ask the merchant to open the app when they return. This is simpler but can break the first background event of the season before the merchant visits the app.

The third is tiered behavior. Proactively maintain credentials for shops with enabled background services or paid active subscriptions, and allow truly dormant or paused installations to reauthorize on return. The product definition should drive this, not engineering convenience.

Create a credential health view:

select
  shop_id,
  status,
  refresh_token_expires_at,
  refresh_token_expires_at - now() as remaining,
  last_refresh_succeeded_at
from shopify_credentials
where token_mode = 'expiring_offline'
  and status not in ('uninstalled', 'revoked')
order by refresh_token_expires_at asc;

Do not email merchants on every approaching expiry without checking whether automatic refresh is available. A message that says “your app connection is expiring” can create unnecessary support demand when the system is designed to rotate itself.

When reauthorization is required, preserve pending work. The app launch completes token acquisition, commits a new generation, and emits a credential_restored event. Blocked jobs can resume through reconciliation. Avoid replaying every historical event without business cutoffs.

Uninstall is different from refresh expiry. Shopify app-uninstall handling should mark the installation unavailable, remove or schedule deletion of credentials according to policy, and stop background work. A relaunch cannot repair an uninstalled app without a new installation flow.

Scopes can also change on return. A fresh token may have a different granted set. Revalidate required scopes before releasing blocked jobs.

A seasonal merchant should not discover the auth model when their first order arrives. Your product needs an explicit dormant-shop policy.

How do you roll this out without exchanging every merchant at once?

Do not start with the largest batch your database can select. Start with the smallest batch that can teach you something.

The rollout has five phases.

Phase one is dark readiness. Deploy the new schema, token service, generation logging, refresh code, and dashboard while existing shops still use non-expiring tokens. New installs use expiring offline tokens. Existing callers route through the token service even though it returns the static credential. This proves the service path before irreversible exchange.

Phase two is internal and development shops. Exercise token acquisition, expiry simulation, refresh, response-loss recovery, uninstall, scope change, and reauthorization. Force concurrency. Kill the worker after the refresh request. Delay the database. Read through a stale cache. These tests should be designed, not hoped for.

Phase three is a small production canary. Choose shops with active use, reachable owners, representative API patterns, and low business risk. Do not choose only quiet shops, because the token will not see real concurrency. Do not choose the highest-volume merchant first.

Phase four expands by cohort. Cohorts can be based on API volume, app plan, feature usage, region, data residency, token age, or integration type. Hold between cohorts long enough to observe at least one access-token rotation and the background jobs that matter.

Phase five handles the tail. Dormant shops, paused subscriptions, unusual scopes, failed prior migrations, and shops requiring merchant relaunch need separate treatment. The last few percent often contain most of the odd states.

Each cohort has stop conditions:

cohort: production_canary_03
shops: "[COUNT]"
start_at: "[TIMESTAMP]"
stop_when:
  definitive_refresh_failures: "[THRESHOLD]"
  lost_exchange_outcomes: "[THRESHOLD]"
  auth_related_job_failure_rate: "[THRESHOLD]"
  blocked_webhook_age: "[THRESHOLD]"
manual_review_owner: "[ROLE]"
rollback: "pause further exchanges, retain expiring shops on new path"

There is no true rollback from a successfully exchanged shop to its old non-expiring token. The rollout rollback means stop migrating more shops and keep already migrated shops on the new credential path. That is another reason to test the full application first.

Migration workers need rate control. Token exchange and verification create API traffic. Add cohort limits and jitter. Run the migration independently from peak webhook and sync windows when possible, but do not rely on “off hours” for a global merchant base.

Report shop states clearly:

legacy_ready
exchange_in_progress
expiring_verified
expiring_refresh_verified
reauth_required
migration_outcome_unknown
uninstalled

Do not collapse expiring_verified and expiring_refresh_verified. A newly exchanged access token can work while the refresh path remains untested. Keep a shop in the rollout cohort until at least one controlled or natural refresh succeeds.

// [WORKFLOW JSON: cohort migration workflow with shop selection, per-shop lock, token exchange, atomic persistence, safe verification query, delayed refresh verification, stop conditions, and incident routing. This proves migration is progressive rather than a bulk script.]

[REAL NUMBER] Insert actual cohort sizes, successful exchanges, first-refresh success rate, paused cohorts, outcome-unknown attempts, and merchants requiring relaunch. Do not publish projected completion as achieved progress.

The deadline rewards early rollout because you need time for the tail, not because the exchange code takes long to write.

What breaks under load, failover, and clock drift?

At scale, small timing assumptions become credential loss.

Load creates synchronized refresh. If many shops installed or migrated in the same batch, their access tokens may expire near the same time. Proactive refresh with a fixed threshold can recreate that synchronization every hour. Add per-shop jitter and spread migration cohorts.

Failover creates duplicate refreshers. A worker acquires a lease in region A. The region loses connectivity after Shopify processes the request. Region B sees an expired lease and takes over. The second region must resume the same refresh attempt where possible, not invent a new source credential. Persist the attempt before the network call.

Database replication creates stale reads. Token reads for Admin API calls can tolerate the prior access token until it expires, based on Shopify's behavior. Refresh decisions cannot use a lagging replica. Send credential-state reads that can trigger rotation to the primary.

Clock drift creates early 401s or unnecessary refresh. Application servers should use synchronized time. Still, subtract a safety window because exact agreement is not guaranteed. Record both expires_at and the source response receipt time.

Queue delay invalidates tokens acquired too early. A job may request an access token, wait in another queue, then execute near expiry. Pass a minimumValidityMs requirement to the token service or acquire the token immediately before the API request.

Process memory creates stale generations. Long-lived workers should not hold access tokens indefinitely. A generation-aware cache with short lifetime and invalidation is safer. A message can carry the shop ID, not the token.

Connection pooling can hold locks or transactions longer than expected. If refresh uses a database transaction around the network request, a slow Shopify response consumes a connection. At high concurrency, the token system can exhaust the application pool. The lease pattern reduces that risk, but it needs careful crash recovery.

Encryption-key rotation adds another generation. A token can be current but encrypted under an older key. The token service should decrypt by key version and re-encrypt during credential rotation or a dedicated key-migration job. Do not confuse cryptographic key version with Shopify credential generation.

Rate limits can turn proactive refresh into self-inflicted pressure. The refresh endpoint and Admin API have platform constraints that may evolve. Measure response headers and current Shopify guidance. Do not publish an unverified universal rate number.

Large outages create a retry storm. Use exponential backoff with jitter, bounded by the documented one-hour refresh-response recovery behavior. A central circuit breaker can slow retries across workers while preserving each attempt's source refresh token and deadline.

const delayMs = Math.min(
  MAX_BACKOFF_MS,
  BASE_BACKOFF_MS * 2 ** retryAttempt,
) + randomJitterMs();

A circuit breaker must be per dependency, not per merchant state. Shopify being unavailable does not mean every refresh token is invalid. Do not mark thousands of shops reauth_required because a shared 5xx outage occurred.

[DIAGRAM] Draw two regions, primary Postgres, read replicas, token refresh leases, encrypted attempt records, and a shared circuit breaker. Show one response lost in region A and recovered in region B with the same source refresh token.

Scale tests should include failure timing. Throughput alone will not expose the state transition that loses the only valid refresh token.

What does the safer design cost you?

It costs more than adding three columns to a session table.

You need a token service or shared library with strict ownership. You need database migrations, encryption, a credential event log, per-shop locks or leases, stale-attempt recovery, and dashboards. You need to change every Admin API caller, including old cron jobs and one-off support scripts that bypass the main SDK.

Testing becomes more involved. You need fault injection around network response loss, database commit failure, process crash, stale cache, and concurrent workers. Local happy-path OAuth tests do not cover those cases.

Proactive maintenance creates recurring work. A job must find credentials approaching refresh-token expiry and rotate according to product policy. That job needs rate control, alerts, and its own idempotency.

The design also creates operational states the support team must understand. reauth_required, uninstalled, scope_missing, and refresh_outcome_unknown need different customer messages and recovery steps. A generic “reconnect Shopify” button is sometimes wrong and can create another credential generation while jobs still use the old one.

Encryption adds latency and key-management responsibility. A managed key service may impose cost and availability dependencies. Application-level encryption complicates ad hoc database debugging, which is a feature for secret protection but a friction for operators.

The migration cannot be fully rolled back after successful exchange. That increases the value of cohort controls and increases the time required for release review.

There is also a product tradeoff for dormant shops. Proactive refresh preserves background access but keeps credentials active. Lazy expiry reduces ongoing credential activity but requires merchant relaunch. The correct choice depends on the service promise.

A total-cost record should include:

engineering:
  token_service: "[ACTUAL HOURS]"
  caller_migrations: "[ACTUAL HOURS]"
  fault_testing: "[ACTUAL HOURS]"
operations:
  credential_health_review: "[ACTUAL HOURS PER PERIOD]"
  reauthorization_support: "[ACTUAL]"
infrastructure:
  database_and_queue: "[ACTUAL]"
  key_management: "[ACTUAL]"
risk:
  blocked_jobs: "[ACTUAL OR UNVERIFIED]"
  lost_credentials: "[ACTUAL OR UNVERIFIED]"

[REAL NUMBER] Insert actual build time, number of Admin API call sites changed, migration duration, credential incidents, support contacts, and recurring infrastructure cost. Do not claim that rotation saves money unless measured.

The safer design costs engineering because it converts a hidden assumption into an owned subsystem. That is exactly what the platform change requires.

What would I do differently before rotating the first production shop?

I would search the entire codebase and automation estate for Shopify tokens before designing the schema.

The official SDK may not be the only caller. n8n HTTP Request nodes, Make scenarios, support scripts, serverless functions, data warehouse jobs, and old Next.js API routes can all read the token directly. If one bypass remains, the app will still fail after rotation.

I would create one internal API for obtaining access tokens and deny direct database access to token columns from ordinary services. Architecture rules work better when permissions enforce them.

I would deploy generation logging before migration. That gives a baseline and reveals caches or workers that keep old credentials longer than expected.

I would test the exact documented response-loss behavior. Drop the connection after the refresh request leaves the process, then retry with the same source refresh token and verify that one new generation commits. I would record the test date because platform contracts can change.

I would separate initial legacy exchange from normal refresh in code and metrics. They have different recovery properties and different risk. A dashboard row called token_rotation_failed hides too much.

I would build a shop health page before the rollout. It should show token mode, generation, access expiry, refresh expiry, last success, last error, scope status, blocked jobs, and required operator action. It should never show token values.

I would choose canary shops with real background activity and reachable owners. A dormant development shop proves exchange, not production behavior.

I would retain a pause switch at three levels: global migration pause, cohort pause, and per-shop pause. The refresh service must continue operating for already migrated shops even when migration is paused.

create table shopify_migration_controls (
  control_key text primary key,
  enabled boolean not null,
  reason text,
  changed_by text not null,
  changed_at timestamptz not null default now()
);

I would also define the last safe date based on the merchant tail, not January 1, 2027. Shops that need relaunch, unusual scope recovery, or support cannot all be handled during the final week.

The code for the refresh grant is the smallest part. Finding every place that assumed the token never changed is the real migration.

What remains hard after every token rotates correctly?

Authorization state is still not business state.

A perfectly rotated token can call Shopify while a background job applies stale assumptions, retries a non-idempotent mutation, processes webhooks out of order, or updates the wrong inventory location. Token correctness removes one failure boundary. It does not make the integration correct.

The open loop from the 2:07 a.m. incident closes with a single-flight token service. The first worker acquires the per-shop lease and refreshes. The second waits and rereads. The third cannot refresh from a stale replica. A lost response resumes with the same refresh token inside Shopify's documented recovery behavior. The job inbox preserves work until the credential is restored.

That architecture prevents the rotation itself from corrupting access. It also gives you evidence when a shop needs merchant relaunch rather than another retry.

The remaining hard problem is deciding whether an Admin API operation is safe to repeat after authentication failed at an uncertain point. Reads are usually straightforward. Mutations are not. You need a business idempotency key, target-state verification, or a reconciliation path for each one.

I can look at a token schema or a failing refresh trace, but the question most teams still avoid is what their worker should do when the credential changes after Shopify may already have accepted the mutation.