-
-
Notifications
You must be signed in to change notification settings - Fork 178
Expiring Offline Access Tokens
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.
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\ShopModelorOsiset\ShopifyApp\Contracts\Commands\Shopwithout using the package's default traits/commands, you must update your implementations to includehasExpiringOfflineAccess()and the optional parameters onsetAccessToken. -
Encryption Dependencies: Refresh tokens are encrypted before being stored in your database. Ensure your Laravel
APP_KEYremains stable, as changing it will prevent the package from decrypting and rotating your merchants' keys.
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 migrateToggle the core feature flag within your .env file to begin requesting expiring tokens during OAuth:
SHOPIFY_EXPIRING_OFFLINE_TOKENS=true| 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.
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-runflag in production environments before executing a migration.
You have three pathways to manage this migration:
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.
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=redisEnv/config queue settings apply to both manual CLI runs and scheduled jobs. CLI flags override config for that run only.
Using the
syncqueue 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--shopoption with a comma-separated list:php artisan shopify-app:migrate-expiring-offline-tokens \ --shop=store1.myshopify.com,store2.myshopify.com,store3.myshopify.comIf 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.
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.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:
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=trueIf 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('...');
}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('...');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.
| 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 |
# 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=redisEach 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).
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-tokensOr 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.
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.