Shopify API returns 500 Internal Server Error for OAuth token exchange in production

I am experiencing a 500 Internal Server Error when my app tries to exchange an ID token for an offline access token using the /admin/oauth/access_token endpoint. This issue only occurs in my production environment (AWS App Runner); everything works as expected in development.

The response from Shopify is always a 500 error with no body or headers.

  • My app is using the official @shopify/shopify-app-remix SDK and the same credentials work in development.

Troubleshooting steps taken:

  • Verified all environment variables and credentials.

  • Confirmed app is installed and API scopes are correct.

  • Enabled debug logging in the SDK, but no additional error details are provided.

Logs:

[shopify-app/INFO] Authenticating admin request
[shopify-app/DEBUG] Attempting to authenticate session token | {sessionToken: {"search": "[REDACTED]"}}
[shopify-app/DEBUG] Validating session token
[shopify-app/DEBUG] Session token is valid | {payload: [REDACTED] }
[shopify-app/DEBUG] Session token is valid | {shop: vurdere-luisa.myshopify.com, payload: [object Object]}
[shopify-app/DEBUG] Loading session from storage | {sessionId: offline_vurdere-luisa.myshopify.com}
[shopify-app/INFO] No valid session found
[shopify-app/INFO] Requesting offline access token
[shopify-api/DEBUG] Making HTTP request | {method: POST, url: https://vurdere-luisa.myshopify.com/admin/oauth/access_token, body: {"client_id":"[REDACTED]","client_secret":"[REDACTED]","grant_type":"urn:ietf:params:oauth:grant-type:token-exchange","subject_token":"[REDACTED]","subject_token_type":"urn:ietf:params:oauth:token-type:id_token","requested_token_type":"urn:shopify:params:oauth:token-type:offline-access-token"}}
[shopify-app/DEBUG] Authenticate returned a response
[my app's logs]
Response status: 500
Response headers: 
{}
Response body: 
⚠️ Error: Response {
  status: 500,
  statusText: 'Internal Server Error',
  headers: Headers 
{}
,
  body: null,
  bodyUsed: false,
  ok: false,
  redirected: false,
  type: 'default',
  url: ''
}
GET /?embedded=1&hmac=[REDACTED]&host=[REDACTED]&id_token=[REDACTED]&locale=en&session=[REDACTED]&shop=vurdere-luisa.myshopify.com&timestamp=1761086795 500 - - 270.792 ms
GET /healthz 200 - - 3.224 ms

Could you please help me understand why this error is occurring in production and how to resolve it?
If you need more details or logs, I can provide them.

Thank you!

More information:

shopify.server.ts

const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET || '',
  apiVersion: ApiVersion.October24,
  scopes: process.env.SCOPES?.split(','),
  appUrl: process.env.SHOPIFY_APP_URL || '',
  authPathPrefix: '/auth',
  logger: { level: LogSeverity.Debug, httpRequests: true },
  sessionStorage: new PrismaSessionStorage(prisma),
  distribution: AppDistribution.AppStore,
  future: {
    unstable_newEmbeddedAuthStrategy: true,
    removeRest: true,
  },
  ...(process.env.SHOP_CUSTOM_DOMAIN ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] } : {}),
});

package.json

"dependencies": {
    "@prisma/client": "^5.11.0",
    "@remix-run/dev": "^2.7.1",
    "@remix-run/fs-routes": "^2.15.0",
    "@remix-run/node": "^2.7.1",
    "@remix-run/react": "^2.7.1",
    "@remix-run/serve": "^2.7.1",
    "@shopify/app-bridge-react": "^4.1.6",
    "@shopify/polaris": "^12.0.0",
    "@shopify/shopify-app-remix": "^3.4.0",
    "@shopify/shopify-app-session-storage-prisma": "^5.1.5",
    "i18next": "^25.3.6",
    "i18next-browser-languagedetector": "^8.2.0",
    "isbot": "^5.1.0",
    "prisma": "^5.11.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-i18next": "^15.6.1",
    "remix-flat-routes": "^0.8.5",
    "vite-tsconfig-paths": "^5.0.1"
  },

And for debugging purposes, here is my current _index.tsx where the authenticate fails:

export const loader = async ({ request }: LoaderFunctionArgs) => {
  console.log('Loader auth started', request.url);

  // Test database connection first
  try {
    await prisma.$queryRaw`SELECT 1`;
    console.log('âś… Database connection test passed');
  } catch (dbError) {
    console.log('❌ Database connection test failed:', dbError);
    throw new Error('Database connection failed');
  }

  // Test if we can create a session with the expected Shopify format
  try {
    console.log('🔍 Testing Shopify-format session creation...');
    const shopifyFormatSession = await prisma.session.create({
      data: {
        id: 'offline_vurdere-luisa.myshopify.com',
        shop: 'vurdere-luisa.myshopify.com',
        state: 'test_state_' + Date.now(),
        isOnline: false,
        accessToken: 'test_token_' + Date.now(),
        scope: 'read_products,write_products',
        userId: 135306707316n, // Use the actual user ID from the logs
      }
    });
    console.log('âś… Shopify-format session creation test passed:', shopifyFormatSession.id);
    
    // Clean up
    await prisma.session.delete({ where: { id: shopifyFormatSession.id } });
    console.log('âś… Session cleanup passed');
  } catch (sessionError) {
    console.log('❌ Shopify-format session creation test failed:', sessionError);
  }

  try {
    console.log('🔍 Starting authentication...');
    const result = await authenticate.admin(request);
    console.log('âś… Authentication successful');
    return result;
  } catch (error: any) {
    console.log('Loader auth error caught');
    

    // Log SDK error details if available
    if (error?.response) {
      console.log('Shopify API error response:', {
        status: error.response.status,
        statusText: error.response.statusText,
        headers: error.response.headers,
      });
      try {
        const body = await error.response.text();
        console.log('Shopify API error body:', body);
      } catch (e) {
        console.log('Could not read Shopify API error body');
      }
    }

    // Existing Response handling
    if (error instanceof Response) {
      console.log('Response status:', error.status);
      console.log('Response headers:', Object.fromEntries(error.headers.entries()));
      try {
        const text = await error.clone().text();
        console.log('Response body:', text);
      } catch (e) {
        console.log('Could not read response body');
      }
    }
    console.log('⚠️ Error:', error);
    throw error;
  }
};

Nice bug report.

Have you tried calling the session token <> access token exchange API directly using cURL?

Here’s the API docs: Exchange a session token for an access token

I assume you’re storing the valid session token, so you could at least then determine if there’s a production configuration issue, or truly a bug in the JS SDK if you’re able to produce an offline access token from the API directly.

Thank you Dylan, so I tried the curl you suggested and here are more information about it:

I created an embedded app using the shopify-cli. Authentication and authorization work perfectly in my development environment without any custom handling.

This is the first install of the app in a store, so this is the first time the app is running await authenticate.admin(request)
From my understanding, this function should handle both session and access tokens, saving the session token in my database automatically (I confirmed this is what is happening in development using Prisma Studio).

However, in production (AWS App Runner), the promise from authenticate.admin(request) always throws an error, so I never get the session or access token to save.

All those logs in my first post that are prefixed with [shopify-app/DEBUG] and [shopify-app/INFO] are from the Shopify SDK (enabled via the logger config).

To try your suggestion, I copied the payload from my App Runner logs and made a manual curl request to the /admin/oauth/access_token endpoint. This request returned a 200 response with the access token and scopes, confirming the payload is valid and Shopify is issuing tokens.

Despite this, my production app never receives the session or access token from the SDK, and I don’t know how to debug further since all authentication is handled internally by the SDK.

To try your suggestion, I copied the payload from my App Runner logs and made a manual curl request to the /admin/oauth/access_token endpoint. This request returned a 200 response with the access token and scopes, confirming the payload is valid and Shopify is issuing tokens.

Great, so the SDK is able to interpret and retrieve the session token. That’s a good sign.

In production, is the session token stored in your database during authentication, despite the error?

It might be some kind of issue with a AppRunner itself, perhaps there’s an Promise that’s not awaited properly, so the AppRunner processing is ending early.

So the issue was that internally my backend was not being able to write the tokens in my DB. Unfortunately the logs didn’t gave me a better clue.

Thank you for your help debugging. I was having difficulties trying to connect to my DB in prod cause of the VPC/subnets and security groups. Unfortunately my knowledge is a bit limited regarding those so I can’t really explain what made it work. It seems it was a subnet that was not well configured.
From the minute where I figure it out how to connect to my DB through a EC2 instance, now the authenticate function is working perfectly!

I was having difficulties trying to connect to my DB in prod cause of the VPC/subnets and security groups.

Oh that would have been a helpful detail to know!

If your app cannot connect to your database, that would explain everything. The session token is simply a JWT that is signed with your private key. It doesn’t require a database connection to validate.

The problem is most likely the SDK attempts to persist it, and then perhaps query for it, and there’s the issue.

Yes sec groups and vpcs can be tricky in AWS. I assume you’re using RDS for your database layer, make sure your EC2 instance sec group is allowed to connect to your RDS database AND the port is correct.

After digging a little dipper, here is the official solution:

The issue was that my App Runner service was deployed in a private subnet (for security), but it couldn’t make outbound HTTPS requests to Shopify’s API, resulting in ETIMEDOUT errors.

The problem: Internet Gateways only work for resources with public IPs, and App Runner in a private subnet doesn’t have one.

The solution: Create a NAT Gateway in a public subnet with an Elastic IP, then configure the private subnet’s route table to send all outbound traffic (0.0.0.0/0) through the NAT Gateway. This allows App Runner to reach external APIs like Shopify while remaining private and secure—the NAT Gateway acts as a middleman, forwarding requests to the internet via the Internet Gateway, but blocking all inbound connections. This is the standard AWS pattern for private resources that need internet access.

1 Like