Skip to content

Expiring Offline Access Tokens

JonPurvis edited this page Jul 14, 2026 · 3 revisions

Expiring Offline Access Tokens & Token Rotation

From April 1, 2026, Shopify mandates that all new public apps use expiring offline access tokens. Existing public apps must migrate to this mechanism by January 1, 2027.

To help developers meet these requirements seamlessly, v27.0.0 of the package introduces full native support for token rotation, automated token refreshing, zero-downtime batch migration for existing merchants (via Shopify's Token Exchange grant), and execution safeguards for long-running queue jobs. v27.1.0 adds queue and connection targeting for the migrate and refresh batch commands so jobs can land on a dedicated worker instead of the app default queue.


Prerequisites & Breaking Changes

Because this feature introduces substantial changes to the underlying merchant lifecycle, it is shipped as part of a major version bump (v27). Please note the following before upgrading:

  • Interface Updates: If you manually implement Osiset\ShopifyApp\Contracts\ShopModel or Osiset\ShopifyApp\Contracts\Commands\Shop without using the package's default traits/commands, you must update your implementations to include hasExpiringOfflineAccess() and the optional parameters on setAccessToken.
  • Encryption Dependencies: Refresh tokens are encrypted before being stored in your database. Ensure your Laravel APP_KEY remains stable, as changing it will prevent the package from decrypting and rotating your merchants' keys.

Initial Setup

1. Run Package Migrations

The package requires new database columns on your shops (or equivalent) table to store the encrypted refresh tokens and expiration timestamps (shopify_offline_refresh_token, shopify_offline_access_token_expires_at, and shopify_offline_refresh_token_expires_at).

Run the package migrations to update your schema:

php artisan migrate

2. Configure Environment Variables

Toggle the core feature flag within your .env file to begin requesting expiring tokens during OAuth:

SHOPIFY_EXPIRING_OFFLINE_TOKENS=true

Advanced Configuration Options

Env Variable Config Key Default Purpose
SHOPIFY_AUTO_MIGRATE_LEGACY auto_migrate_legacy true Enables passive, transparent token exchange migration on a legacy merchant's first API interaction.
SHOPIFY_REFRESH_OFFLINE_TOKEN_BEFORE_API_CALL refresh_offline_token_before_api_call false Evaluates token expiration before every API call—essential for long-running queue worker loops.
SHOPIFY_OFFLINE_ACCESS_TOKEN_REFRESH_SKEW_SECONDS offline_access_token_refresh_skew_seconds 0 Allows you to specify a safety buffer (in seconds) to refresh tokens early before they fully expire.
SHOPIFY_OFFLINE_REFRESH_TOKEN_RENEWAL_DAYS offline_refresh_token_renewal_days 14 Shops whose refresh token expires within this many days are queued for renewal by the refresh CLI.
SHOPIFY_MIGRATE_OFFLINE_TOKENS_JOB_QUEUE job_queues.migrate_expiring_offline_tokens null Queue for MigrateShopTokenJob (default app queue when unset).
SHOPIFY_MIGRATE_OFFLINE_TOKENS_JOB_CONNECTION job_connections.migrate_expiring_offline_tokens null Queue connection for migrate jobs.
SHOPIFY_REFRESH_OFFLINE_TOKENS_JOB_QUEUE job_queues.refresh_expiring_offline_tokens null Queue for RefreshShopOfflineTokenJob.
SHOPIFY_REFRESH_OFFLINE_TOKENS_JOB_CONNECTION job_connections.refresh_expiring_offline_tokens null Queue connection for refresh jobs.

Resolution order for migrate/refresh batch jobs: CLI --queue= / --connection= → config / env → app default queue. Scheduled Schedule::command(...) runs pick up env/config automatically; you can also bake --queue= into the schedule command string.


Migrating Existing Merchants (Token Exchange)

When you toggle SHOPIFY_EXPIRING_OFFLINE_TOKENS=true, existing shops with a legacy token stored in the password column are not automatically upgraded by Shopify. You must exchange your existing legacy tokens for expiring ones.

Important: The token exchange migration is a one-way operation. Shopify permanently revokes the old non-expiring token immediately upon a successful exchange. It is highly recommended to use the --dry-run flag in production environments before executing a migration.

You have three pathways to manage this migration:

Option A: Passive / Lazy Migration (Default)

With SHOPIFY_AUTO_MIGRATE_LEGACY=true enabled, you do not need to run any manual upgrade scripts.

  • The next time an existing merchant initiates a web request or triggers an API call via $shop->api(), the package performs the token exchange inline.
  • Fail-Open Safeguard: If the inline migration fails due to a network blip or an external Shopify API error, a warning is logged, and the package safely falls back to using the legacy token for that request so the merchant's experience is not blocked.

Option B: Proactive Batch Migration via Queue (Recommended)

For applications with large merchant counts, you can proactively batch-migrate your database using a built-in Artisan command. This utilizes a cache lock to prevent race conditions and dispatches isolated MigrateShopTokenJob instances to your queue, making it serverless and Laravel Vapor-friendly.

# 1. Run a dry-run to preview how many shops qualify for migration
php artisan shopify-app:migrate-expiring-offline-tokens --dry-run

# 2. Dispatch migration jobs for all legacy shops to your queue
php artisan shopify-app:migrate-expiring-offline-tokens

# 3. Target a single specific shop for testing or troubleshooting
php artisan shopify-app:migrate-expiring-offline-tokens --shop=example.myshopify.com

# 4. Target a dedicated worker (CLI override; optional --connection)
php artisan shopify-app:migrate-expiring-offline-tokens --queue=shopify-tokens
php artisan shopify-app:migrate-expiring-offline-tokens --queue=shopify-tokens --connection=redis

Env/config queue settings apply to both manual CLI runs and scheduled jobs. CLI flags override config for that run only.

Using the sync queue driver with a large number of shops? The command processes all shops in a single run, making a live Shopify API call for each one. On large datasets (thousands of shops) this can take a long time and is vulnerable to process interruption. In this case it is recommended to either switch to an async queue driver for the migration, or process shops in smaller batches using the --shop option with a comma-separated list:

php artisan shopify-app:migrate-expiring-offline-tokens \
  --shop=store1.myshopify.com,store2.myshopify.com,store3.myshopify.com

If a shop fails (e.g. a stale token from a store that has uninstalled your app), a warning is emitted and the command continues with the remaining shops.

Option C: Programmatic Migration

If you prefer to build a custom UI workflow, an admin dashboard toggle, or a specialized script, you can execute the underlying action class directly:

use Kyon147\LaravelShopify\Actions\MigrateShopToExpiringOfflineAccessToken;

$result = app(MigrateShopToExpiringOfflineAccessToken::class)($shop);

// The returned result object allows you to inspect: migrated, skipped, reason, or error.

Handling Long-Running Queue Jobs

Because Laravel queue workers are long-running daemon processes, they memoize (cache) the API client on the $shop model instance. If a background job processes data for hours, it may hit 401 Unauthorized errors mid-lifecycle if the access token expires after the job started.

You can handle this in three different ways depending on your architecture:

1. Global Pre-Flight Check (Opt-In)

Turn on the global pre-flight check in your .env. This forces the package to inspect the token's remaining lifespan before every single API call, automatically clearing and rebuilding the client with a fresh token if it falls within the expiration or skew window:

SHOPIFY_REFRESH_OFFLINE_TOKEN_BEFORE_API_CALL=true

2. Explicit Pre-Flight Check (Per Loop Iteration)

If you are iterating over large datasets inside a single job and want to avoid global pre-flight overhead on every single API interaction, you can selectively check freshness at key milestones in your loops:

foreach ($orders as $order) {
    if (! $shop->offlineAccessTokenIsFresh()) {
        $shop->refreshOfflineAccessTokenIfNeeded();
    }
    
    $shop->api()->graph('...');
}

3. Manual Client Reset

If you previously relied on manual workarounds such as setting $shop->apiHelper = null to clear out stale client instances inside long-running tasks, you should update your codebase to use the official public helper:

// Clear the cached client so the subsequent call is forced to pull a freshly rotated token
$shop->resetApiClient();

$shop->api()->graph('...');

Proactive Renewal for Dormant Shops

Refresh tokens expire after approximately 90 days. Shops with no API activity (no webhooks, no scheduled jobs, no merchant visits) can lapse if nothing triggers a refresh before then.

The package provides an opt-in Artisan command to renew tokens before the refresh token expires. It does not register a schedule automatically — you add it to your app's scheduler.

Configuration

Env Variable Config Key Default Purpose
SHOPIFY_OFFLINE_REFRESH_TOKEN_RENEWAL_DAYS offline_refresh_token_renewal_days 14 Shops whose refresh token expires within this many days are queued for renewal

Command

# Preview shops that would be renewed
php artisan shopify-app:refresh-expiring-offline-tokens --dry-run

# Dispatch renewal jobs for all shops within the renewal window
php artisan shopify-app:refresh-expiring-offline-tokens

# Target a single shop or override the window
php artisan shopify-app:refresh-expiring-offline-tokens --shop=example.myshopify.com
php artisan shopify-app:refresh-expiring-offline-tokens --days=7

# Target a dedicated worker (CLI override; optional --connection)
php artisan shopify-app:refresh-expiring-offline-tokens --queue=shopify-tokens
php artisan shopify-app:refresh-expiring-offline-tokens --queue=shopify-tokens --connection=redis

Each matching shop is dispatched as a RefreshShopOfflineTokenJob to your queue (or the configured / CLI-overridden queue). The job calls Shopify's refresh grant, which rotates both the access token and refresh token (~90 days reset).

Scheduler (app-owned)

Add to your app's routes/console.php or app/Console/Kernel.php:

Schedule::command('shopify-app:refresh-expiring-offline-tokens')->daily();

With env/config set, scheduled runs use that queue automatically:

SHOPIFY_REFRESH_OFFLINE_TOKENS_JOB_QUEUE=shopify-tokens

Or bake the override into the schedule command:

Schedule::command('shopify-app:refresh-expiring-offline-tokens --queue=shopify-tokens')->daily();

Note: Active shops that regularly call the API or receive webhooks are refreshed automatically and typically do not need this command. It is intended for apps with long-idle installs.


Housekeeping & Uninstalls

When a merchant uninstalls your app, the package's internal cleanup routines (such as the clean() method inside ShopCommand) have been extended to automatically purge the stored refresh token and associated expiration timestamps alongside the old access token.

Welcome to the wiki!

Please see the homepage for a list of relevant pages.

Clone this wiki locally