What I learned moving a Shopify app to expiring access tokens

a single security access key with a circular clock face and rotation refresh arrows wrapping around it, representing an expiring and rotating authentication token

Shopify is making expiring offline access tokens required for all public apps on January 1, 2027. New apps already have to use them since April 2026.

I decided to do it now, well ahead of the deadline, on purpose. The closer we get to Q4 the worse the timing gets. Q4 is the busiest and most stressful time of the year for e-commerce and for Shopify apps, and touching authentication in the middle of that is the last thing I want. A token migration is much safer to ship in a quiet month than when traffic is peaking and every small bug turns into a fire. So I got it out of the way early.

A few things surprised me along the way, so I wrote them down.

My app does bulk product operations, so it runs a lot of long background jobs. That turned out to matter a lot for this feature. More on that below.

In this post

Quick primer

Until now offline access tokens never expired. With the new system you get two tokens:

access_token   ->  expires in 1 hour
refresh_token  ->  expires in 90 days

When the access token expires you call Shopify with the refresh token and get a fresh one back. Simple enough. Two things I didn’t expect.

First, the refresh token rotates. Every time you refresh, Shopify gives you a new refresh token too and kills the old one. The 90 day timer resets each time. So as long as a shop is active at least once every 90 days, the refresh token basically never expires. The 90 days only matters for shops that go completely quiet.

So what about a shop that does go quiet for that long and then comes back? I worried about this one too. It turned out to be fine. If the refresh token has already expired, the next time the merchant opens the app it just runs the OAuth grant again and gets a fresh pair of tokens, the same path I use to migrate old shops. I don’t even try the dead refresh token against Shopify, my stored expiry already tells me it’s gone, so I send them straight to re-auth. The merchant does nothing, just a quick redirect, and the shop is back to normal. I tested this by pushing the stored expiry dates into the past so a shop looked like it had been idle for 90 days, and it recovered on the next open.

Second, you opt in with a single parameter. You add expiring=1 to the token request, the server side call that trades the OAuth code for an access token (the POST to /admin/oauth/access_token), not the screen where the merchant approves the app. That one field is the whole opt in, and it makes Shopify hand back a refresh token and an expiry instead of a permanent token. You only send it when you first get the token, not on refreshes. You don’t have to wait for 2027, you can turn it on and test today.

One more thing that confused me at first. I thought this might depend on the Admin API version. It does not. The OAuth token endpoints are not versioned at all, so your API version has nothing to do with it.

How I approached it

I use a Laravel package for the Shopify side (the Osiset / kyon147 one). My version is old, and the version that supports expiring tokens natively is many major releases ahead. I didn’t want a big risky upgrade just for this. So I overrode the one method that exchanges the code for a token, added expiring=1 there, and wrote the refresh logic myself. If you are on a library too, check this first. You might not need to upgrade, just override the token exchange step.

For existing shops I went with re-auth on app open. Existing shops already have a non expiring token, and you don’t want to force a reinstall. You can do it with token exchange in the background, or you can send the shop through OAuth again with expiring=1 the next time they open the app. I picked the second one. Scopes don’t change, so the merchant just sees a quick redirect, no permission screen. My app is on demand anyway, you have to open it to do anything, so by the time a shop needs a token the merchant is right there.

The parts that tripped me up

Save the new refresh token every time

This is the easiest one to get wrong. Because the refresh token rotates, if you refresh the access token but forget to also save the new refresh token, your next refresh fails. Save everything back each time: new access token, new refresh token, and both expiry dates.

Two workers refreshing at once will fight each other

This one doesn’t show up until you have some load. The old refresh token dies the moment you use it. I run many queue workers, and one shop can have several jobs running at the same time. So two workers hit the refresh at the same moment, the first one wins and rotates the token, and the second one is now holding a dead token and fails.

I fixed it with a lock per shop around the refresh, and re-reading the token inside the lock. The second worker waits, sees the token is already fresh, and just uses it. Shopify does give a short grace period where the old token still works right after rotation, but I wouldn’t depend on that.

Long jobs run past the one hour

My jobs run for hours. The access token lives one hour. If you grab the token once at the start of the job and reuse it, it is dead an hour later. So I check the token before every API call and refresh it when it is close to expiring. The check is cheap, just comparing timestamps, and the real refresh only happens when it is actually needed. The point is your background jobs have to refresh on their own. You can’t lean on web requests for it.

Store the expiry as a number, not a date

This one cost me real time. I first stored the expiry as a normal datetime. My app switches the process timezone in the middle of long jobs (it logs things in the shop’s local time). So an expiry written during a job in the shop timezone, then read later in a web request in UTC, came out wrong. For a shop in Tokyo it was off by 9 hours. For shops east of UTC that means an expired token looks valid, which gives you 401s in production.

I switched to storing the expiry as a plain Unix timestamp and comparing against the current epoch. A timestamp is absolute, it doesn’t care about your timezone. Even if your app never touches the timezone, “is this time in the past” is something you want safe from timezone bugs, so I would just store token expiries as numbers from the start.

Re-auth can re-trigger your install code

When I migrate a shop by sending it back through OAuth, my framework treats it like a fresh install. It re-fired the “app installed” event, and my listener for that sends me a notification and stamps an install date. If I had shipped it as is, every shop migrating would have pinged me and reset its install date.

I added a small flag, a short lived cache entry per shop, that I set right before the redirect. The install listener skips the notification and the date when it sees that flag. Real installs still work normally. Whatever stack you are on, check what runs after install before you start re-authing existing shops.

How I tested it before 2027

You don’t need to wait for the deadline, since expiring=1 works now.

  • Turn it on on a dev store. The token really does expire in an hour, and you get a real refresh token.
  • To test the refresh, don’t wait an hour. Set the stored expiry to a time in the past and make an API call. The refresh runs right away.
  • After a refresh or migration, try a call with the old token. You get a 401. That is your proof the old token was actually killed.
  • Test two jobs on the same shop at once, and a token expiring mid job. That is the case most likely to break in production.

One thing that wasted an hour and was not a token bug at all. My app went totally blank in Safari while testing locally, and I was sure it was auth. It was my local dev server serving scripts over http into an https iframe, which Safari blocks as mixed content (Chrome allows localhost, so it worked there). If your embedded app goes blank, check the console for mixed content before blaming your tokens.

That’s it

None of this was hard once I understood it. But a couple of these, the rotation race and the timezone one, would have been ugly bugs in production if I had found them later. If you run background jobs or have a lot of shops, those are the two I would watch first.