AuthShopifyBackendCustomer Accounts

Shopify CAPI Authentication: Going Along With Shopify, Not Against It

Shopify only does email login, so we built our own phone and OTP login on our own page. That leaves two separate memories of who is signed in, and neither automatically knows what the other is thinking. Here is how we keep them in sync.

Pratham BhatiaPratham BhatiaSDE-1
Aug 21, 2026
8 min read
Shopify CAPI Authentication: Going Along With Shopify, Not Against It
Fig. 01A dispatch on auth
Share this article

The problem: Multipass, the old way of doing headless login on Shopify, is being retired for Shopify's new Customer Account API. On that new flow the browser has to pass through shopify.com at some point no matter what you build, that part is baked into how OAuth works and nobody escapes it. What you do get to choose is whose login screen appears, and Shopify's own one only takes an email, no phone, no OTP. In India almost everyone signs in by phone number, so that is a hard mismatch, not a preference.

The solution: register our own login page with Shopify as the identity provider. Now whenever Shopify needs someone identified it comes to us instead of rendering its own screen, so the only login page the customer ever sees is ours, phone number and OTP. The browser still transits shopify.com on the way through, but no Shopify login screen ever appears.

Shopify's own login screen sits behind, dimmed and never shown, while our own phone and OTP login page sits in front, which is all the customer ever sees

Phone and email are linked onto the same customer at signup, so a phone login on our side resolves to a real Shopify customer on theirs, and Shopify still gets told who is signed in, quietly, so checkout and order history keep working.

The catch: that leaves two separate memories of who is logged in, ours and Shopify's. Most of the time they agree, but neither one updates or erases the other, and that single fact explains almost everything about how login and logout behave.

So we handled every way the two could drift apart, deliberately, instead of hoping they stay in step. It is live on bombayshirts.com today. Five situations, at a glance:

The five scenarios at a glance: a normal logout, our session gone while Shopify's survives, a fresh login meeting an old Shopify session, Shopify starting the logout, and Shopify starting the login

Scenario 1: A normal logout

The customer clicks "Log out" on our website. It looks like one click, but two separate memories have to be updated, not one, so our backend does this in four steps, in this exact order.

Step 1: Read the id token first, while the record still exists. Shopify will only end its session if we hand back the id token stored on our session record. Delete the record first and that token is gone for good, so this has to happen before anything is cleaned up.

Step 2: Build Shopify's logout link from it. buildShopifyLogoutUrl produces the same logout Shopify would run if the customer had clicked logout on its own account screen.

Step 3: Delete our own session record. This removes our system's note that says "this customer has an active session." From our side, they are now logged out.

Step 4: Clear our own short-lived cache. On our servers, separate from the session record, sits a small cache saying "this session was checked recently, keep trusting it for about a minute." Leave it behind and the logged out session keeps working for that minute.

Then the browser makes a quick trip through that Shopify logout link and comes straight back. It is a redirect, not a screen, so the customer sees no Shopify page, but Shopify's own session genuinely ends.

Scenario 1: a normal logout clears both our session and Shopify's session in order

Once all four steps finish, both sides genuinely agree: nobody is logged in anymore, on either system.

Scenario 2: Our site says logged out, Shopify disagrees

This is not something we build on purpose. It is just what can happen once two separate memories exist, and it plays out in three steps.

Step 1: Our side loses track of the session, without a clean goodbye. This can happen a few different ways. The session naturally runs out after its normal lifetime. The record gets removed for some other reason. Or a proper logout was attempted, but the final call telling Shopify to end its own session failed to go through.

Step 2: Our website now correctly reports the customer as logged out. From our own records, there is nothing left to say otherwise, so this part is accurate.

Step 3: Shopify was never told, so it still says the customer is logged in. Shopify only updates its own session when something explicitly asks it to. If that ask never happened, or never succeeded, Shopify's memory simply keeps going as if nothing changed.

Scenario 2: our website has logged the customer out, but Shopify still has an active session for them

Nothing breaks immediately when this happens. But it is exactly the setup that makes the next scenario possible.

Scenario 3: A new login walks into an old memory

This is the tricky one, and the reason we built an extra safety check into our login flow at all. It happens in four steps.

Step 1: A customer logs into our website with a one time code. Our system checks the code and is completely confident about who this is. Call this person Customer A.

Step 2: The browser still has an old session remembered by Shopify. Maybe from an earlier visit, maybe from a different person who used the same browser before. Either way, Shopify already has something stored for this browser.

Step 3: Shopify reuses that old session instead of asking us again. Normally, Shopify would come back to our system and ask who this customer is. Here, because it already has a session on file, it skips that step and simply hands back whatever identity that old session belonged to. That identity might be Customer A again, or it might genuinely be someone else.

Step 4: Our system does not trust that answer blindly. Instead, it asks Shopify directly who the session actually belongs to, and compares that against Customer A, the identity our own one time code already confirmed.

Scenario 3: comparing the OTP identity against Shopify's session identity, and what happens on a match versus a mismatch

If the two match: nothing was actually wrong, Shopify's old session simply belonged to the same customer. The login continues normally, with the one extra check making it a touch slower than usual.

If the two do not match: we throw away the session we just created, before it ever reaches the customer's browser. Then buildShopifyLogoutUrl runs again, silently, to clear the stale session on Shopify's side.

That cleanup is the part that matters. Once Shopify's stale session is gone there is nothing left for it to reuse, so the next attempt starts fresh and resolves to the right person. Skip it and every retry would keep landing on the same wrong customer until that session expired on its own.

Scenario 4: Shopify starts the logout, not us

Every scenario so far starts with our own website. This one runs the other way. It happens when a customer clicks "Sign out" from inside a screen that Shopify itself controls, most commonly Shopify's own checkout, rather than from our website.

Step 1: Shopify calls our logout endpoint. It also tells us where to send the customer once we are done, which is the checkout page they were already on.

Step 2: We end our own session and send them straight back. They stay right where they clicked sign out, now logged out on both sides.

Scenario 4: Shopify starts the logout, calls our logout endpoint, and the customer returns to the page they were on

The useful thing to notice here is the direction. Keeping two memories in sync is not only about us reaching out to Shopify, Shopify reaches out to us too, and our side has to answer properly when it does.

Scenario 5: Shopify starts the login, not us

The mirror of Scenario 4. A customer is already in checkout, not signed in, and clicks "Sign in" there. That click goes to Shopify, not to us, so the login begins on Shopify's side.

Step 1: Shopify hands the browser over to us. It is asking us to identify this person. Nothing is held open on a wire, we just record a pending request on our side, good for ten minutes, and Shopify waits for the browser to come back carrying an answer.

Step 2: We show our own login page, phone number and OTP. Same page, same design as always. The customer never sees a Shopify login screen even though Shopify is the one who asked.

Step 3: Once the code is verified, we answer Shopify. We tell it who this customer is, Shopify closes out the login it was holding, and the browser lands back in checkout, signed in.

Scenario 5: Shopify starts the login, waits while our own OTP page verifies the customer, then the customer returns to checkout signed in

The difference from a normal login is who moves first. Here Shopify asked, so we answer it directly. On our own site nothing is pending on Shopify's side, so once the code is verified we start the handshake with Shopify ourselves, and the session that comes back from it is the one the customer actually ends up holding. Same OTP page either way, two different orders of events underneath.

A smaller detail: how we find the customer at all

When someone types a phone number, we check our own customer table first. A hit takes a few milliseconds and never touches Shopify at all. A miss means a real call to Shopify's Admin API, a few hundred milliseconds, and we save that answer so the next login for that person is a hit.

No data migration was needed to get here, because all 612,884 customers already lived in Shopify. Rather than copying them across upfront, each one fills themselves in the first time they log in, so the share of logins that still need Shopify keeps shrinking on its own.

Customer lookup order: check our own database first, then Shopify on a miss, then write the result back so the next login is fast

Why this design, and not something simpler

It would be far simpler to just trust whatever identity Shopify hands back. Fewer checks, faster logins, less code.

But that simplicity only holds up if you assume the two memories always agree. They don't, not because either system is broken, but because they are genuinely separate, and separate things drift apart over time. A session that outlives a logout, a browser that gets reused, a customer who logs in from checkout and then again from our own site minutes later, any of these can put Shopify's memory slightly out of step with ours.

Once you accept that the two memories can disagree, the only honest option left is to check. Not to distrust Shopify, but to confirm, every single time, that "the person who just proved themselves to us" and "the person Shopify's session belongs to" are actually the same person. Most of the time that check passes instantly and nobody notices it happened at all. Occasionally it catches something real, and that's exactly the moment it exists for.

That's really the whole idea behind the title. We're not fighting Shopify's own login behavior or trying to route around it. We're going along with it, while keeping one small, quiet check in place for the moments where going along with it blindly would have been the wrong call..

Pratham Bhatia

Pratham Bhatia

SDE-1

Continue reading

All articles →
Our AI SDLC: 8 Stages, 3 Agent Roles, 6 Gates
ai sdlc

Our AI SDLC: 8 Stages, 3 Agent Roles, 6 Gates

The lifecycle we now run every project through at devx: eight stages, three agent roles with different tool permissions, and six checks that fail the build.

Jaimin MalaviyaJul 30 · 18 min
Pyramid, Diamond, Pod
future of work

Pyramid, Diamond, Pod

Learnings from the ground: The pyramid was never the product. Judgment was. And a better amplifier for judgment now exists!

Yash ThakkerJul 12 · 6 min