remix-run/remix · error · Error

Expected OAuth provider to return a JSON object.

Error message

Expected OAuth provider to return a JSON object.

What it means

Thrown by normalizeOAuthTokenResponse after the OAuth token endpoint responds. The provider's token exchange endpoint returned HTTP 200 but the body parsed to something other than a JSON object (e.g. an array, a string, a number, or null). The library validates the shape before extracting access_token/refresh_token.

Source

Thrown at packages/auth/src/lib/provider.ts:302

            Authorization: `Basic ${encodeBasicAuth(options.clientId, options.clientSecret)}`,
          }
        : undefined),
      ...options.headers,
    },
    body: params,
  })
  let json = await readJson(response)

  if (!response.ok || hasOAuthError(json)) {
    throw new Error(getOAuthErrorMessage(json, options.fallbackError))
  }

  return normalizeOAuthTokenResponse(json)
}

function normalizeOAuthTokenResponse(json: unknown): OAuthTokens {
  if (typeof json !== 'object' || json == null || Array.isArray(json)) {
    throw new Error('Expected OAuth provider to return a JSON object.')
  }

  let data = json as Record<string, unknown>

  if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
    throw new Error('OAuth token response did not include an access token.')
  }

  return {
    accessToken: data.access_token,
    refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
    tokenType: typeof data.token_type === 'string' ? data.token_type : undefined,
    expiresAt:
      typeof data.expires_in === 'number'
        ? new Date(Date.now() + data.expires_in * 1000)
        : undefined,
    scope: parseScope(data.scope),
    idToken: typeof data.id_token === 'string' ? data.id_token : undefined,

View on GitHub (pinned to 9696913134)

Solutions

  1. Inspect the raw token endpoint response body for the provider (curl the token URL with the same parameters) to see what it actually returns
  2. Verify the token endpoint URL in provider metadata/config is correct and points at the OAuth2 token endpoint, not another API route
  3. Check for proxies, API gateways, or middleware that rewrite the token response
  4. If the provider is non-standard, wrap or adapt its token response before it reaches exchangeOAuthTokens

Example fix

// before
let tokens = await exchangeOAuthTokens({ tokenEndpoint: 'https://provider.example.com/api/v2/tokens', ... })

// after — use the actual OAuth2 token endpoint
let tokens = await exchangeOAuthTokens({ tokenEndpoint: 'https://provider.example.com/oauth/token', ... })
Defensive patterns

Strategy: try-catch

Type guard

function isJsonObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
}

Try / catch

try {
  let { tokens } = await provider.handleCallback(request)
} catch (error) {
  if (error instanceof Error && error.message === 'Expected OAuth provider to return a JSON object.') {
    // provider returned malformed token payload — inspect provider config/endpoint
  }
  throw error
}

Prevention

When it happens

Trigger: Any OAuth provider's token endpoint (Facebook, GitHub, OIDC) returns a 200 response whose JSON body is not a plain object — for example a JSON array, a bare string, or `null`. Reached via exchangeOAuthTokens during handleCallback or refreshTokens.

Common situations: Misconfigured token endpoint URL that returns JSON of an unexpected shape (e.g. hitting a list endpoint), a proxy/gateway that rewrites responses, HTML error pages parsed as JSON, or an upstream API version change altering the response envelope.


AI-assisted analysis of remix-run/remix@9696913134 (2026-08-27). Data as JSON: /api/errors/7c8f20a5e8f292ee. Report an issue: GitHub.