Shopify Auth & Sessions
What authenticate.admin actually does, the two different tokens everyone confuses, and why most Shopify app bugs start right here.
The confusion to clear up first
There are two completely different tokens in a Shopify app. They have similar names, they do opposite jobs, and mixing them up is the source of an enormous share of beginner bugs.
Session token
Browser → your app.
Proves "a logged-in Shopify admin user is making this request." App Bridge creates it and attaches it automatically to every request your pages make.
Expires after about a minute, on purpose.
Access token
Your app → Shopify.
The long-lived secret your server uses to read and write shop data through the Admin API.
Stored in your database. Never leaves your server.
The session token lets the browser prove itself to you. The access token lets you prove yourself to Shopify. Your job is mostly to trade the first for the second and store it.
What a "session" actually is
Despite the name, it is not a login cookie. It is simply a row in your database holding one shop's access token.
model Session {
id String @id // "offline_shop.myshopify.com"
shop String // "shop.myshopify.com"
accessToken String // the secret for calling the API
scope String? // permissions this token has
isOnline Boolean @default(false)
expires DateTime?
}
That is why sessionStorage: new PrismaSessionStorage(prisma) appears in shopify.server.js — it tells the library to keep these rows in your database, so installs survive a restart.
One row per installed shop. Ten shops installed, ten rows.
For an offline session the id is always offline_<shop> — predictable and stable. That matters more than it sounds: it means a shop that uninstalls and reinstalls lands back in the same row, rather than creating a duplicate.
Offline vs online sessions
Offline (the default)
One token for the whole shop. Works when nobody is logged in. It does expire, but the library refreshes it for you — the template turns on expiringOfflineAccessTokens in shopify.server.js.
Use for: almost everything, and anything running on a schedule.
Online
One token per staff member, expiring after about 24 hours.
Use for: when you must know which person did something, or enforce per-user permissions.
Unless you turned on useOnlineTokens, you are using offline sessions. Keep it that way while you are learning — background jobs and webhooks need a token that works with nobody logged in.
How a request gets authenticated
Modern Shopify apps use token exchange. Here is what happens on a page load, in order:
Step 4 only happens the first time; after that the token is read from your database.
- Your page loads inside the Shopify admin. App Bridge gets a fresh session token and attaches it to the request.
authenticate.admin(request)verifies that token is genuine and identifies the shop.- It looks for a stored session for that shop.
- If none exists (or it is no longer valid), it exchanges the session token for an access token and saves it.
- You get back
sessionandadmin.
You never write any of this. You write one line.
The four authenticate functions
Which one you need depends entirely on who is calling you.
| Caller | Use | In |
|---|---|---|
| A merchant in the admin | authenticate.admin(request) | Your /app/* pages |
| Shopify telling you something | authenticate.webhook(request) | Webhook routes |
| A shopper on the storefront | authenticate.public.appProxy(request) | App proxy routes |
| Nobody — a scheduled job | unauthenticated.admin(shop) | Cron jobs |
In a page
export const loader = async ({ request }) => {
const { session, admin } = await authenticate.admin(request);
session.shop; // "shop.myshopify.com" — scope your DB queries with this
session.id; // "offline_shop.myshopify.com" — the session row itself
admin.graphql; // call the Shopify API
};
In a background job
A cron job at 3am has no request to authenticate, so it loads the stored token directly:
const { admin } = await unauthenticated.admin("shop.myshopify.com");
await admin.graphql(`...`);
This works even though offline tokens expire: before using the stored token, the library checks it and refreshes it if needed. A job never has to log in — another reason to stay with offline sessions.
The security rule you must not forget
Your database holds data for every shop that installed your app. Nothing stops one shop's request from reading another's rows — except your where clause.
// WRONG — returns every shop's products to whoever asks
const products = await db.product.findMany();
// RIGHT — scoped to the shop that made this request
const products = await db.product.findMany({
where: { shop: session.shop },
});
This bug is invisible in development, because you only have one shop installed. Everything looks perfect. It becomes a serious data breach the moment a second merchant installs.
Habit: every query touching shop data gets shop in its where clause. Every single one — reads, updates and deletes.
// Deletes must be scoped too, or one shop can delete another's row
await db.product.deleteMany({
where: { id: Number(form.get("id")), shop: session.shop },
});
Whatever a loader returns is visible in the browser's developer tools.
return { session }; // leaks the access token!
return { shop: session.shop }; // safe
Scopes: asking for permission
A scope is one permission, like read_products. Your app can only do what its scopes allow, and they live in two places:
# shopify.app.toml
[access_scopes]
scopes = "read_products,write_products"
# SCOPES env var — shopify.server.js reads it at runtime.
# shopify app dev sets it for you; on your server you set it.
SCOPES=read_products,write_products
Adding a scope means: update both places, deploy, and the merchant is asked to approve the new permission. Until they do, the new capability does not work. (On a dev store, a running shopify app dev grants new scopes automatically.)
Update the TOML, forget the .env on your production server, and the app breaks in production while working perfectly on your machine. Always check both, in every environment.
Uninstall and reinstall
When a merchant uninstalls, Shopify immediately revokes the access token and calls your app/uninstalled webhook. Your stored token is now a dead string.
The standard template's uninstall handler does this:
await db.session.deleteMany({ where: { shop } });
That part is right — the token is already revoked, so the row is useless. The danger is any table of yours linked to Session with a relation. With onDelete: Cascade, that one delete silently deletes everything the shop ever created — every product, setting and upload — the instant anyone clicks uninstall. Without it, the database refuses the delete and the uninstall webhook crashes on every retry (Note 05).
Safer: give your tables a plain shop column and leave the template's handler exactly as it is. The shop's data survives the uninstall, and a reinstall finds it again by shop domain.
For public apps: Shopify sends a shop/redact webhook 48 hours after uninstall, and you must erase the shop's data then — by shop, because the session is long gone. See Note 15.
On reinstall there is no session row, so the first request fetches a fresh token — in the shopify app dev terminal you see No valid session found, then Requesting offline access token. The merchant sees nothing.
The one-minute session token problem
Remember: session tokens expire in about a minute. Usually irrelevant, because requests take milliseconds.
It becomes very relevant with slow requests — uploading a large file, for example. If the upload takes longer than the token's life, the request fails with a 401 that makes no sense, because you were logged in.
Keep requests to your own server small and fast. For big uploads, send the file straight from the browser to its destination and let your server handle only tiny JSON messages before and after.
There is a full note on uploads later in this series.
Error decoder
401 Unauthorized
Three usual causes, in order of likelihood:
- A request took longer than the session token's lifetime (see above).
- You are calling your app outside the Shopify admin, so there is no session token at all.
- The stored access token was revoked — the shop uninstalled.
"App Bridge: missing required configuration fields: shop"
You opened your app's URL directly in a browser tab instead of through the Shopify admin. Embedded pages need the shop and host values that the admin supplies.
Open the app from the admin's Apps menu, or press p in your shopify app dev terminal.
Blank page inside the Shopify admin
Usually your app URL no longer matches the tunnel address. Restart shopify app dev, which rewrites the URLs. Add --reset if it persists.
A new permission does nothing
Check all three: the scope is in the TOML, the scope is in .env (including production), and the merchant approved the re-authorization prompt.
My cron job gets "no session found"
Either that shop was never installed, or its session row was deleted on uninstall. Note that jobs will keep failing for uninstalled shops — that is expected; skip shops whose token no longer works.
Checklist for every new route
- Called by a merchant? Start with
authenticate.admin(request). - Called by Shopify or a shopper? Use
authenticate.webhookorauthenticate.public.appProxy— and remember these files must not be namedapp.*. - Every database query has
shopin itswhereclause — including deletes. - The loader returns only the fields the page displays. Never the session.
- New capability? Scope added to the TOML — and to
SCOPESon your production server (Note 11). In development the CLI handles it.
Cheat sheet
# two tokens
session token browser → your app ~1 minute App Bridge handles it
access token your app → Shopify long-lived stored in your DB
# who is calling?
merchant in admin authenticate.admin(request)
Shopify webhook authenticate.webhook(request)
storefront shopper authenticate.public.appProxy(request)
scheduled job unauthenticated.admin(shop)
# what you get back
session.shop which shop — scope every query with this
admin.graphql call the Shopify API
# never
db.product.findMany() unscoped — leaks other shops
return { session } leaks the access token
@relation to Session breaks uninstall (Note 05)