CORS errors are the most frustrating errors in OAuth development. The browser blocks your request, the error message is generic, and the actual cause could be any of 8+ different scenarios. This guide covers every CORS error you’ll encounter in OAuth 2.0 and OIDC flows, with exact browser error messages and provider-specific fixes.
Quick Diagnostic: Which Error Are You Seeing?
| Browser Console Error | Jump To |
|---|---|
No 'Access-Control-Allow-Origin' header on /authorize | Scenario 1: Calling /authorize via fetch |
No 'Access-Control-Allow-Origin' header on /token | Scenario 2: Token endpoint CORS |
AADSTS9002327: Cross-origin token redemption | Scenario 3: Azure AD SPA registration |
| CORS error only after session timeout | Scenario 4: Keycloak error response bug |
wildcard '*' when credentials mode is 'include' | Scenario 5: Wildcard with credentials |
Response to preflight request doesn't pass | Scenario 6: Preflight failures |
CORS error on /revoke endpoint | Scenario 7: Token revocation |
| Everything works except in production | Scenario 8: Proxy/CDN stripping headers |
Which OAuth Endpoints Support CORS?
Before debugging, know which endpoints are designed to accept cross-origin requests:
| Endpoint | CORS Support | Access Method |
|---|---|---|
/authorize | No | Browser redirect (window.location.href) |
/token (Auth Code + PKCE, public client) | Yes | fetch from SPA |
/token (client_credentials) | No | Server-to-server only |
/userinfo | Yes | fetch with Authorization: Bearer |
/.well-known/openid-configuration | Yes | fetch |
/jwks | Yes | fetch (used by OIDC libraries) |
/revoke | Provider-dependent | Prefer server-side |
/logout | No | Browser redirect |
Scenario 1: Calling /authorize via fetch
This is the #1 most common CORS error in OAuth. The /authorize endpoint is a browser-navigation endpoint — it responds with a 302 redirect to the login UI.
Fix: Never call /authorize via fetch() or XMLHttpRequest. Use a browser redirect:
// WRONG — will always cause a CORS error
const response = await fetch(`${authServerUrl}/authorize?...`);
// CORRECT — browser redirect, bypasses CORS entirely
window.location.href = `${authServerUrl}/authorize?` + new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: state
});
This applies to every OAuth provider — Keycloak, Auth0, Okta, Azure AD, Google, ForgeRock, Cognito. No provider enables CORS on the authorize endpoint.
Scenario 2: Token Endpoint CORS Errors
The token endpoint /token supports CORS for public client flows (Authorization Code + PKCE) but NOT for confidential client flows (client_credentials).
Provider CORS Configuration
Keycloak: Admin Console → Clients → [Client] → Access Settings → Web Origins
Common mistakes:
- Leaving Web Origins blank (CORS fails for all requests)
- Adding a trailing slash:
https://myapp.example.com/(invalid) - Adding a path:
https://myapp.example.com/*(paths not allowed in origins)
Okta: Admin Console → Security → API → Trusted Origins
- Click “Add Origin”
- Enter
https://myapp.example.com - Check the CORS checkbox
- Save
Auth0: Dashboard → Applications → [App] → Settings → Allowed Origins (CORS)
Also set Allowed Web Origins if using Auth0’s cross-origin authentication.
ForgeRock AM: Configure → Global Services → CORS Service → Accepted Origins
Or configure per-client via the OAuth 2.0 client’s JavaScript Origins field — ForgeRock AM automatically adds these to the CORS allowlist.
AWS Cognito: No admin CORS configuration. Cognito’s token endpoint accepts cross-origin requests from any origin for public clients.
Scenario 3: Azure AD SPA Registration
Azure AD / Microsoft Entra ID determines CORS eligibility based on the redirect URI platform type in the app registration — not by any CORS configuration setting.
Fix: Go to App Registration → Authentication → Add platform → Single-page application → add your redirect URI. If it’s currently registered under “Web”, move it to “Single-page application”.
Azure AD also blocks client_credentials from browsers entirely. If a browser sends an Origin header with a confidential flow, Entra rejects it to prevent secrets from leaking in client-side code.
Scenario 4: Keycloak CORS on Error Responses
This bug (KEYCLOAK-1886) causes CORS errors that appear only after session expiry (see Keycloak Session Timeout Configuration for session timeout details):
- User logs in → SPA exchanges code for tokens → works fine (200 with CORS headers)
- Session expires → SPA tries to refresh → Keycloak returns 400
invalid_grantwithout CORS headers - Browser sees 400 + no
Access-Control-Allow-Origin→ throws CORS error - The SPA never sees the actual
invalid_grant— only a CORS error
Workaround with nginx:
location /realms/ {
proxy_pass http://keycloak:8080;
# 'always' ensures CORS headers on ALL responses including 4xx/5xx
add_header 'Access-Control-Allow-Origin' 'https://myapp.example.com' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://myapp.example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
add_header 'Access-Control-Max-Age' 86400;
return 204;
}
}
The key is the always directive — without it, nginx only adds headers to 2xx responses.
Scenario 5: Wildcard Origin with Credentials
This happens when:
- The server returns
Access-Control-Allow-Origin: * - The client sends
credentials: 'include'orwithCredentials: true - The CORS spec forbids this combination
Fix: Replace * with the exact origin and add the credentials header:
In Keycloak, this means using the exact origin in Web Origins instead of *.
Scenario 6: Preflight Request Failures
A preflight OPTIONS request is triggered when your request uses:
Content-Type: application/json(notapplication/x-www-form-urlencoded)- Custom headers like
AuthorizationorX-Custom-Header - HTTP methods other than GET, HEAD, or POST
Key insight: The token endpoint uses Content-Type: application/x-www-form-urlencoded by default. If you accidentally set Content-Type: application/json, you trigger a preflight that many OAuth servers don’t handle:
// WRONG — triggers unnecessary preflight
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'authorization_code', code, ... })
});
// CORRECT — no preflight needed (simple request)
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'authorization_code', code, ... })
});
Spring Security preflight fix:
http.cors().and()
.csrf().disable()
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll();
Scenario 7: Token Revocation CORS
Google’s /revoke endpoint does NOT support CORS. Calling it from a SPA via fetch always fails.
Workaround — form POST (bypasses CORS):
const form = document.createElement('form');
form.method = 'POST';
form.action = 'https://oauth2.googleapis.com/revoke';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'token';
input.value = accessToken;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
Auth0’s /oauth/revoke requires the app domain in Allowed Origins (CORS) in the dashboard.
Scenario 8: Proxy or CDN Stripping CORS Headers
Everything works locally but fails in production. Common causes:
- CloudFlare / CDN caching a response without CORS headers, then serving it to cross-origin requests
- nginx/Apache reverse proxy not forwarding the
Originheader to the backend - Load balancer stripping
Access-Control-*headers
Debug checklist:
# Test directly against the OAuth server (bypass proxy)
curl -v -H "Origin: https://myapp.example.com" \
https://auth.example.com/realms/myrealm/protocol/openid-connect/token
# Check if CORS headers are in the response
# Look for: Access-Control-Allow-Origin: https://myapp.example.com
# Test through your proxy
curl -v -H "Origin: https://myapp.example.com" \
https://your-proxy.example.com/auth/token
# Compare the headers — if the proxy response is missing CORS headers,
# the proxy is stripping them
When CORS Errors Mean Wrong Architecture
If you’re hitting CORS errors with client_credentials, on_behalf_of, or any confidential client flow from a SPA — the problem isn’t CORS configuration, it’s your architecture.
SPAs should NOT:
- Hold
client_secretin JavaScript - Call
/tokenwithclient_credentialsgrant - Store refresh tokens in
localStorage
Use the Backend for Frontend (BFF) pattern instead:
The BFF handles all OAuth token operations server-side. The SPA only communicates with the BFF using HttpOnly, SameSite cookies. No CORS issues because the SPA and BFF share the same origin.
For implementation details, see Integrating OAuth 2.0 with React SPA Using BFF.
Debugging with Browser DevTools
- Open DevTools → Network tab
- Filter by the failing request URL
- Check the Headers tab:
- Request Headers: Look for
Origin: https://myapp.example.com - Response Headers: Look for
Access-Control-Allow-Origin
- Request Headers: Look for
- If the response has no
Access-Control-Allow-Origin, the server isn’t configured for your origin - Check the Console tab for the exact CORS error message
- Look for a preceding
OPTIONSrequest — if it failed or returned non-2xx, the preflight is the problem
# Replicate the request with curl to confirm it's a CORS issue (not a server error)
curl -v -X POST https://auth.example.com/token \
-H "Origin: https://myapp.example.com" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&code=CODE&redirect_uri=URI&client_id=ID&code_verifier=VERIFIER"
If curl succeeds (returns tokens), the server works but is missing CORS headers for your origin. Configure the provider as described above.
