Skip to content

Migration Guide: Expiring Offline Access Tokens

Like God bestowing the Ten Commandments upon Moses at the top of Mount Sinai, Shopify has issued a new exhortation for us developer converts to its green temple: starting on January 1, 2027, all public apps on Shopify’s App Store must have completed a migration off of the (now legacy) permanent offline access tokens, and over to the new expiring offline access tokens. After that date, any existing permanent offline access tokens will be rejected.

While the new expiring token type does invite additional busywork and latency into our apps (by necessitating a request just to refresh the token that gives us permission to send requests), the migration itself is fairly straightforward, and ShopifySharp v6.29.0+ has support for the entire process.

This guide covers everything you’ll need to do migrate off of those legacy offline access tokens and over to the new expiring offline access tokens using ShopifySharp.

Shopify’s API has four kinds of access tokens which can be obtained via the OAuth process. They are the Online Access Token, the Permanent Offline Access Token, the Expiring Offline Access Token and the Delegate Access Token. There’s also Custom App Passwords, but those are manually generated by the store owner, not via OAuth, so they’re not relevant here.

Token type Purpose Expiration
Online Access Token Used to authenticate individual merchant staff accounts with your app. Permissions are scoped to the staff account’s own permissions. Expires when the staff account session expires (i.e. when the user logs out of the Shopify store.)
Permanent Offline Access Token An access token that can be used at any time, without a user or staff account logged in. Only expires if the merchant uninstalls your app.
Expiring Offline Access Token Just like the Permanent Offline Access Token, except it expires and must be refreshed using the refresh token it’s paired with. Access tokens expire after 60 minutes, but the refresh token lasts 90 days.
Delegate Access Token Derived from offline access tokens, these are meant for subsystems, bots, and so on that don’t need the full set of permissions that comes with the offline access token. ShopifySharp does not currently support creating these. These expire when their parent token expires.

Up until v6.29.0, ShopifySharp only supported the Online Access Token and the Permanent Offline Access Token. It did not support the Expiring Offline Access Token, which means if you use ShopifySharp and your app is using these Permanent Offline Access Tokens (what I’d describe as the “default” access token up until now), then you’re using the legacy token that Shopify wants you to migrate off of before January 1, 2027.

Back in April, 2026, Shopify published a new rule requiring any new public app wanting to be published to the Shopify App Store had to be using Expiring Offline Access Tokens. Now, Shopify has updated their requirements again, requiring all public apps to migrate off of the Permanent Offline Access Tokens and over to Expiring Offline Access Tokens by January 1st, 2027. After that date, any app attempting to use one of these legacy offline tokens will receive a 403 Forbidden error.

This requirement applies only to public apps in the Shopify App Store. Custom apps, user apps, and apps created in the Dev Dashboard are not affected by this new requirement.

So why the change? Ostensibly this forced migration is all in the name of security. Permanent Offline Access Tokens are a major weak point in Shopify’s API security, for both Shopify itself and for the applications who hold them. The mere fact that the permanent tokens never expired means that any place they were accidentally written to logs was a potential data leak/attack vector waiting to happen. By requiring all apps to migrate to expiring tokens, Shopify has hugely reduced the possibility that this kind of leak would happen at all. If I get a copy of your logs from three years ago and find permanent access tokens, there’s a chance that some of those tokens are going to work; if I get a copy of your logs from three hours ago and only find expiring access tokens, none of them are going to work.

There are likely other security benefits here too (e.g. if your database is leaked, it’s better to have a database full of tokens that expire after 60 minutes than a database full of tokens that never expire), but I’m not a security expert and won’t waste your time by pretending I know all of the ins-and-outs of what I’m talking about there.

Migrating from permanent tokens to expiring tokens

Section titled “Migrating from permanent tokens to expiring tokens”

Alright, let’s talk about what you’ll need to do to migrate your users from the legacy Permanent Offline Access Tokens to the new Expiring Offline Access Tokens. There are two parts to this: migrating existing users to expiring tokens via “token cycling”, and ensuring new users only get expiring tokens. We’ll cover both methods, but before we start, let’s discuss what you’ll need to change in your database or data store.

At a bare minimum, you must adjust your schemas so that you can store the new expiration timestamp, the refresh token, and the refresh token’s own expiration. This example assumes you’re using something like Entity Framework:

// Represents the user as stored by your database
public class MyUserEntity
{
public int Id { get; set; }
public string ShopDomain { get; set; }
// <other properties here>
[Obsolete("User should migrate to the expiring access token.")]
public string? AccessToken { get; set; }
public string? LegacyAccessToken { get; set; }
public ExpiringAccessToken? ExpiringAccessToken { get; set; }
}
public record ExpiringAccessToken
{
public string AccessToken { get; set; }
public string[]? GrantedScopes { get; set; }
public DateTimeOffset IssuedAtUtc { get; set; }
public TimeSpan? ExpiresIn { get; set; }
public string RefreshToken { get; set; }
public TimeSpan? RefreshTokenExpiresIn { get; set; }
}

So we’ve renamed the old AccessToken string property to LegacyAccessToken, and introduced a new ExpiringAccessToken property which holds all of the token expiration and refresh data. When migrating users, we’ll use the existence of the LegacyAccessToken to determine whether the user still needs to be migrated or not.

We’re going to be converting the new ExpiringAccessToken type to and from ShopifySharp’s AuthorizationResult type, so you might find it helpful to add some helper methods to assist in the conversion:

using ShopifySharp;
public record ExpiringAccessToken
{
// <snipped for brevity>
public AuthorizationResult ToAuthorizationResult() =>
new AuthorizationResult(AccessToken, GrantedScopes)
{
IssuedAtUtc = IssuedAtUtc,
ExpiresIn = ExpiresIn,
RefreshToken = RefreshToken,
RefreshTokenExpiresIn = RefreshTokenExpiresIn
};
public static ExpiringAccessToken FromAuthorizationResult(AuthorizationResult result)
{
if (result.Type != ShopifyAccessTokenType.ExpiringOffline)
throw new ArgumentException($"AuthorizationResult must be {nameof(ShopifyAccessTokenType.ExpiringOffline)}", nameof(result));
return new ExpiringAccessToken
{
AccessToken = result.AccessToken,
GrantedScopes = result.GrantedScopes,
IssuedAtUtc = result.IssuedAtUtc,
ExpiresIn = result.ExpiresIn,
RefreshToken = result.RefreshToken!,
RefreshTokenExpiresIn = result.RefreshTokenExpiresIn
};
}
}

With those changes in place, we can continue on to the migration itself.

Part 1: Use token cycling to migrate existing users

Section titled “Part 1: Use token cycling to migrate existing users”

“Token cycling” is what Shopify calls the process of exchanging a Permanent Offline Access Token for a new Expiring Offline Access Token. When we use token cycling to exchange the tokens, the old permanent token is invalidated by Shopify, and we’re given a new expiring token with a refresh token – exactly as if the user had gone through the OAuth flow.

Unlike the OAuth flow, though, token cycling doesn’t require user interaction. It can be done entirely in the background or as a one-off migration script. This is what Shopify recommends we all do to migrate our legacy tokens before the January 1st, 2027 deadline.

The approach here is pretty simple:

  1. Get a list of your users who still have a legacy Permanent Offline Access Token.
  2. Iterate over the list of users.
  3. For each user, call the new CycleOfflineAccessTokenAsync method on ShopifySharp’s ShopifyOauthUtility class.
  4. The method will exchange the permanent access token for a new expiring offline access token, along with its refresh token.
  5. Attach the new expiring token + refresh token to your user’s record in your database and save it.
  6. Continue to the next user and repeat.

The exact execution details here are going to greatly depend on your own app’s architecture, but for my own apps, I’m planning on stuffing this migration into a BackgroundService job which will run in the background after my app starts up.

Here’s what it looks like:

using ShopifySharp;
using ShopifySharp.Utilities;
public sealed class BackgroundTokenCycleJob(
ILogger<BackgroundTokenCycleJob> logger,
IUserDatabase userDatabase
) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
try
{
await CycleUserTokensAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Normal application shutdown.
}
catch (Exception ex)
{
logger.LogError(ex, "Token Cycle job failed.");
}
}
private async Task CycleUserTokensAsync(CancellationToken ct)
{
// Grab users from the database.
// TODO: you probably want to chunk this instead of pulling your entire list of users into memory all at once
var users = await userDatabase.
.Where(user => user.LegacyAccessToken is not null)
.ToListAsync(ct);
// You can also get this utility from DI if you're using ShopifySharp.Extensions.DependencyInjection
var oauthUtility = new ShopifyOauthUtility();
foreach (var user in users)
{
try
{
var cycleResult = await oauthUtility.CycleOfflineAccessTokenAsync(new CycleOfflineAccessTokenOptions
{
ClientId = YourAppClientId,
ClientSecret = YourAppClientSecret,
ShopDomain = user.ShopDomain,
AccessToken = user.LegacyAccessToken!
}, ct);
// Clear the legacy permanent access token and set the new expiring token
user.LegacyAccessToken = null;
user.ExpiringAccessToken = ExpiringAccessToken.FromAuthorizationResult(cycleResult);
await userDatabase.SaveChangesAsync(ct);
}
catch (ShopifyHttpException ex)
{
logger.LogError(ex, "Failed to cycle token for user {UserId}, Shopify returned HTTP {StatusCode}: {ErrorMessage}", user.Id, ex.HttpStatusCode, ex.Message);
continue;
}
logger.LogInformation("Cycled token for user {UserId}", user.Id);
}
}
}

And that’s it! That’s all it takes to cycle the permanent access tokens over to an expiring offline access token. As the code comment said, I’d adjust the production code so that it pulls out chunks of users, rather than pulling the entire list of users into memory all at once just to iterate through them one at a time.

If you’re going to use a background service like I did in the example above, you’ll want to add it to your app’s hosted services in Startup.cs or Program.cs (or wherever you configure your app’s services):

using Microsoft.Extensions.Hosting;
// ...
services.AddHostedService<BackgroundTokenCycleJob>();

Part 2: Get an expiring offline access token from new users by default

Section titled “Part 2: Get an expiring offline access token from new users by default”

The second part of this migration is making sure that your new users get an Expiring Offline Access Token by default, so they don’t need to go through a token cycling process at all. This is a simple one-line change: during the OAuth flow, when the user comes back from Shopify and they have that temporary code parameter in the querystring, you exchange the code for an access token as usual, but set the new RequestExpiringOfflineToken option to true:

using ShopifySharp.Utilities;
// Always verify that the request is authentic!
var requestValidationUtility = new ShopifyRequestValidationUtility();
if (!requestValidationUtility.IsAuthenticRequest(Request.Query, YourAppSecretKey))
throw new Exception("Invalid request.");
// Get the user somehow
var user = await GetUser();
// Make sure the state value matches what you stored
var storedStateValue = await GetStoredState(user.Id);
if (storedStateValue != Request.Query["state"])
throw new Exception("state value is missing or invalid");
// You can also get this from DI using ShopifySharp.Extensions.DependencyInjection
var oauthUtility = new ShopifyOauthUtility();
// Exchange the temporary code for an expiring offline access token
var authorizationResult = await oauthUtility.AuthorizeAsync(new AuthorizeOptions
{
ClientId = YourAppClientId,
ClientSecret = YourAppClientSecret,
ShopDomain = user.ShopDomain,
Code = Request.Query["code"],
// Set this to true to get an expiring offline token
RequestExpiringOfflineToken = true
});
// Clear the legacy permanent access token (in case this is an existing user)
// and set the new expiring token
user.LegacyAccessToken = null;
user.ExpiringAccessToken = ExpiringAccessToken.FromAuthorizationResult(authorizationResult);
await userDatabase.SaveChangesAsync();

That’s it! The actual change here is literally just one line:

var authorizationResult = await oauthUtility.AuthorizeAsync(new AuthorizeOptions
{
ClientId = YourAppClientId,
ClientSecret = YourAppClientSecret,
ShopDomain = user.ShopDomain,
Code = Request.Query["code"],
// Set this to true to get an expiring offline token
RequestExpiringOfflineToken = true
});

(Well, two lines with the comment.)

After this change, your app will start generating Expiring Offline Access Tokens from new users – or any existing users who go through the OAuth flow – by default.

Refreshing an expired offline access token

Section titled “Refreshing an expired offline access token”

Alright, so we’ve cycled the permanent access tokens and turned them into expiring access tokens, and all of your new users are using expiring tokens by default as well. The last bit of the puzzle here is what you do when an expiring token, well, expires.

According to Shopify’s docs (at least at the time of this writing), an Expiring Offline Access Token expires after just 60 minutes – 1 whole hour. The refresh token, on the other hand, expires either 90 days after issuance or 30 days after its first use, whichever comes first.

On top of that, it’s important to note that the previous refresh token gets invalidated after you use the next refresh token. In practice, this means you should always use the newest refresh token, because the expiration date never changes for a refresh token. There’s a little bit of nuance there, in that you can use the previous refresh token more than once as a recovery method (in case an error, network issue, lightning strike, or an act of God caused the response to be lost or the storage to fail.)

So, divine intervention not withstanding, we should always save and use the newest refresh token whenever possible.

ShopifySharp’s ShopifyOauthUtility has two methods that’ll refresh these expiring access tokens: RefreshOfflineAccessTokenAsync and RefreshOfflineAccessTokenIfStaleAsync. In most cases, I’d recommend using the second one, since it’ll check the expiration on the access token first before attempting to refresh it, saving you a request.

// Get the user somehow
var user = await GetUser();
if (user.ExpiringAccessToken is null)
throw new InvalidOperationException("User does not have an expiring access token");
// You can also get this from DI using ShopifySharp.Extensions.DependencyInjection
var oauthUtility = new ShopifyOauthUtility();
// Refresh the access token if it expired
var authorization = user.ExpiringAccessToken.ToAuthorizationResult();
var refreshedAuthorization = await oauthUtility.RefreshOfflineAccessTokenIfStaleAsync(
new RefreshOfflineAccessTokenIfStaleOptions
{
ShopDomain = user.ShopDomain,
ClientId = YourAppClientId,
ClientSecret = YourAppClientSecret,
RefreshToken = authorization.RefreshToken,
AccessTokenExpiresAtUtc = authorization.AccessTokenExpiresAtUtc,
RefreshTokenExpiresAtUtc = authorization.RefreshTokenExpiresAtUtc,
});

You can also configure how soon it should be refreshed in regard to its expiration timestamp. For instance, if you want to refresh it 5 minutes before it expires:

var refreshedAuthorization = await oauthUtility.RefreshOfflineAccessTokenIfStaleAsync(
new RefreshOfflineAccessTokenIfStaleOptions
{
ShopDomain = user.ShopDomain,
ClientId = YourAppClientId,
ClientSecret = YourAppClientSecret,
RefreshToken = authorization.RefreshToken,
AccessTokenExpiresAtUtc = authorization.AccessTokenExpiresAtUtc,
RefreshTokenExpiresAtUtc = authorization.RefreshTokenExpiresAtUtc,
// Refresh the token 5 minutes before it expires
RefreshBeforeExpiry = TimeSpan.FromMinutes(5)
});

This method returns an AuthorizationResult?, which is null if it didn’t refresh the token. That means you just need to check if the result has a value and, if so, save the updated tokens:

// Check if the expiring offline access token was refreshed
if (refreshedAuthorization != null)
{
// Token was refreshed, update the user
user.ExpiringAccessToken = ExpiringAccessToken.FromAuthorizationResult(refreshedAuthorization);
await userDatabase.SaveChangesAsync();
}

Remember, the access token changes when it’s refreshed, so make sure you’re using the right one to call the Shopify API:

var accessToken = refreshedAuthorization?.AccessToken ?? authorization.AccessToken;

Finally, you’ll want to catch the following exceptions when refreshing the access token:

Exception Thrown when…
ShopifyInvalidRefreshTokenException When ShopifySharp determines the refresh token is invalid or expired based on the timestamp and expiration values you pass to it.
ShopifyHttpException (401 Unauthorized) When the refresh token has already been replaced by another refresh token your app has used, or when Shopify determines the token is expired or otherwise invalid.

Here’s what it all looks like, with those exceptions caught:

// Get the user somehow
var user = await GetUser();
if (user.ExpiringAccessToken is null)
throw new InvalidOperationException("User does not have an expiring access token");
// You can also get this from DI using ShopifySharp.Extensions.DependencyInjection
var oauthUtility = new ShopifyOauthUtility();
AuthorizationResult? refreshedAuthorization;
try
{
refreshedAuthorization = await oauthUtility.RefreshOfflineAccessTokenIfStaleAsync(
new RefreshOfflineAccessTokenIfStaleOptions
{
ShopDomain = user.ShopDomain,
ClientId = YourAppClientId,
ClientSecret = YourAppClientSecret,
RefreshToken = authorization.RefreshToken,
AccessTokenExpiresAtUtc = authorization.AccessTokenExpiresAtUtc,
RefreshTokenExpiresAtUtc = authorization.RefreshTokenExpiresAtUtc,
// Refresh the token 5 minutes before it expires
RefreshBeforeExpiry = TimeSpan.FromMinutes(5)
});
}
catch (ShopifyInvalidRefreshTokenException ex)
{
// The refresh token is invalid or expired.
Console.WriteLine(ex.Message);
// TODO: clear the refresh token and send the user through the OAuth flow
throw;
}
catch (ShopifyHttpException ex) when (ex.HttpStatusCode == HttpStatusCode.Unauthorized)
{
// Shopify indicates this refresh token is invalid (it's been reused, has expired, or is just wonky)
Console.WriteLine(ex.Message);
// TODO: clear the refresh token and send the user through the OAuth flow
throw;
}
// Check if the expiring offline access token was refreshed
if (refreshedAuthorization != null)
{
// Token was refreshed, update the user
user.ExpiringAccessToken = ExpiringAccessToken.FromAuthorizationResult(refreshedAuthorization);
await userDatabase.SaveChangesAsync();
}
// TODO: make a request to the Shopify API
var accessToken = refreshedAuthorization?.AccessToken ?? authorization.AccessToken;

Et voila! This’ll refresh access tokens when they’re 5 minutes or less from expiration, and catch any (hopefully rare) exceptions.

Manually refreshing the token without a staleness check

Section titled “Manually refreshing the token without a staleness check”

The code we just used to refresh the expiring access token uses ShopifySharp’s RefreshOfflineAccessTokenIfStaleAsync, which means it only refreshes the token if it’s, well, stale. The method also requires you to have the access token’s expiration timestamp along with the refresh token’s expiration timestamp.

But nihil novi sub sole, and it’s easy to imagine we might find ourselves in a situation where we don’t have one of those timestamps, or where we just want to refresh the token regardless. You can skip over the staleness check and refresh expiring offline access tokens using the utility’s RefreshOfflineAccessTokenAsync method:

var refreshResult = await oauthUtility.RefreshOfflineAccessTokenAsync(new RefreshOfflineAccessTokenOptions
{
ClientId = YourAppClientId,
ClientSecret = YourAppClientSecret,
RefreshToken = user.ExpiringAccessToken!.RefreshToken,
ShopDomain = user.ShopDomain
});
// This always returns an AuthorizationResult
user.ExpiringAccessToken = ExpiringAccessToken.FromAuthorizationResult(refreshResult);
await userDatabase.SaveChangesAsync();
// TODO: make a request to the Shopify API
var accessToken = refreshResult.AccessToken;

Shopify returns 403 Forbidden after January 1st, 2027

Section titled “Shopify returns 403 Forbidden after January 1st, 2027”

After the January 1st, 2027 migration deadline, Shopify will start returning 403 Forbidden errors for any app using a legacy Permanent Offline Access Token. In ShopifySharp, this will throw a ShopifyHttpException, which you can catch like so:

var service = new GraphService(shopDomain, legacyPermanentAccessToken);
try
{
var foo = await service.PostAsync("query { shop { id } }");
}
catch (ShopifyHttpException ex) when (ex.HttpStatusCode == HttpStatusCode.Forbidden)
{
// TODO: send the user through the OAuth flow and get a new expiring token
throw;
}

Online access tokens – i.e. the kind that are tied to a Shopify store member’s session – already expire with the session. The AuthorizationResult.Type will be ShopifyAccessTokenType.Online and they can’t be refreshed. No migration is needed for this type of token.

A big thank you to @GMatrixGames on Github for contributing most of the code behind the new refresh token methods in PRs #1257 and #1261!