401 Unauthorized after app installation - Sessions not being created in production (React Router v7)

Hi everyone,

I’m building a Shopify app using React Router v7 and Polaris (both React and Web Components), and I’m running into a strange 401 Unauthorized issue that only happens in production.

What’s happening

Right after installation, the app works fine. But after a few hours, the embedded app starts showing 401 Unauthorized instead of loading. If I delete the session from the database, it never gets recreated and the 401 continues.

What makes this tricky is that I can’t reproduce this locally at all .

Setup overview

  • React Router v7
  • Polaris React Components + Polaris Web Components
  • PostgreSQL with Prisma
  • Session storage: @shopify/shopify-app-session-storage-prisma
  • Custom distribution (not App Store)
  • API version: January26 (also tested October25)
  • expiringOfflineAccessTokens: true enabled

Shopify config

Our shopify.server.ts is basically identical to the official template:

const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET || '',
  apiVersion: ApiVersion.January26,
  scopes: process.env.SCOPES?.split(','),
  appUrl: process.env.SHOPIFY_APP_URL || '',
  authPathPrefix: '/auth',
  sessionStorage: new PrismaSessionStorage(prisma),
  distribution: AppDistribution.AppStore,
  future: {
    expiringOfflineAccessTokens: true,
  },
});

And the auth.$.tsx route is also straight from the template:

export const loader = async ({ request }: LoaderFunctionArgs) => {
  await authenticate.admin(request);
  return null;
};

What I’m seeing in production logs

I’ve implemented a custom wrapper which sort of implements the PrismaSessionStorage in a dirty way to get some insights and to be able to add some logs.

This is the consistent flow:

  1. First request to / includes an id_token → redirect to /auth/session-token (302)
  2. /auth/session-token is hit and returns HTML (200)
  3. No session storage logs at all — storeSession() is never called
  4. Second request to / (no id_token) → 401 Unauthorized
  5. Database shows zero sessions for that shop

Relevant log output:

// Log from: /app/routes/auth.$.tsx - loader function
// This is called when user accesses /auth/session-token endpoint
[AUTH-DEBUG] OAuth flow starting {
  pathname: '/auth/session-token',  // The OAuth session-token endpoint
  shop: 'xyz.myshopify.com',
  hasCode: false,      // No OAuth authorization code - OAuth callback hasn't completed yet
  hasHmac: true,       // HMAC signature is present (request is valid)
  hasIdToken: false    // No ID token - user session not established
}

// Log from: /app/routes/auth.$.tsx - catch block
// authenticate.admin() threw a Error response with status 200
// The Response contains HTML that should complete OAuth via JavaScript i guess
[AUTH-DEBUG] OAuth flow redirect (expected) {
  status: 200,  // error.status - This is the session-token HTML page i assume, not an error 
}

// Log from: /app/routes/auth.$.tsx - after catching the Response
// We check the database to see if any sessions were created during this OAuth flow
[AUTH-DEBUG] Existing sessions in DB after session-token {
  shop: 'c11a74-82.myshopify.com',
  count: 0,      // PROBLEM: No sessions exist - session was never created!
  sessions: []   // This confirms that storeSession() was never called
}

Local vs production behavior

  • Locally: Sessions are created, tokens refresh correctly, and deleted/expired sessions get recreated.
  • Production: Sessions are never created during OAuth. Only the very first installation ever produced a session — after that, nothing.

Things I’ve already tried

  • Simplified auth.$.tsx to exactly match the official example
  • Double-checked all environment variables (SHOPIFY_API_KEY, SHOPIFY_API_SECRET, SHOPIFY_APP_URL, SCOPES)
  • Updated Prisma schema for refreshToken and refreshTokenExpires
  • Tested both October25 and January26 API versions
  • Verified DB connectivity (works fine)
  • Confirmed SCOPES is correctly set in Docker Compose

Questions

  1. Why would /auth/session-token return the HTML page (200) but never create a session?
  2. Is the session-token page supposed to trigger another request to complete OAuth? I don’t see any follow-up request in the logs.
  3. Are there known pitfalls that could affect session creation?

If anyone has run into something similar or has ideas on what to check next, I’d really appreciate it. Thanks in advance!

there’s at times a race-condition between the app install and token creation/validity, simplest solution is to check the session token validity in your loader, and show a loading screen while you poll to check if the session is valid. Not ideal, but works.

1 Like

Thank you a lot for the response!

After creating this post, I was shown a related post that I hadn’t found before. In that post, the user mentioned enabling the ShopifyApp logger in shopify.server.ts, which revealed a "nbf" claim timestamp check failed error — exactly the same issue I encountered.

It turned out my server time was about one minute behind. After configuring the firewall to allow outgoing UDP traffic on port 123 (used by NTP) and some ssh/cli magic, the issue was resolved.

xxx:~# timedatectl status
               Local time: Wed 2025-12-17 18:46:43 UTC
           Universal time: Wed 2025-12-17 18:46:43 UTC
                 RTC time: Wed 2025-12-17 18:47:40
                Time zone: Etc/UTC (UTC, +0000)
System clock synchronized: no
              NTP service: active
          RTC in local TZ: no
xxx:~# sudo systemctl enable systemd-timesyncd
xxx:~# sudo systemctl start systemd-timesyncd
xxx:~# sudo timedatectl set-ntp true
xxx:~# sudo systemctl restart systemd-timesyncd
xxx:~# timedatectl status
               Local time: Wed 2025-12-17 18:56:43 UTC
           Universal time: Wed 2025-12-17 18:56:43 UTC
                 RTC time: Wed 2025-12-17 18:56:43
                Time zone: Etc/UTC (UTC, +0000)
System clock synchronized: yes
              NTP service: active
          RTC in local TZ: no

Now it works.