Next.js

Access to fetch at accounts.google.com blocked by CORS: fix

Why cors() on your Express API cannot fix a Google sign-in started with fetch, and the three navigation patterns that make the error disappear.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

TL;DR#

Your React code calls fetch() on an Express route that answers with res.redirect(url) to https://accounts.google.com/o/oauth2/v2/auth. fetch() follows that redirect itself, as a cross-origin request from your page, and the browser checks CORS headers on every hop. Google's authorisation endpoint does not support CORS, so the last hop fails. app.use(cors()) cannot help, because it only sets headers on your own server's responses. The fix is to navigate the tab to the login route (a form, window.location.href, or window.location.assign with the URL) so the sign-in page loads as a page, where CORS does not apply.

The error, as posted in the Stack Overflow question (React on port 3000, Express on port 8000):

plaintext
Access to fetch at 'https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&scope=https%3A%2F%2Fmail.google.com%2F&response_type=code&client_id=727520060136-ngpfd4ll798v42gfclh7cms9ndqstt32.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A8000' (redirected from 'http://localhost:8000/api/mail/login') from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

The two parts that identify this case are the Google URL as the blocked resource and the (redirected from ...) clause naming your own API.

The code that triggers it#

The question's front end posts to the API from a click handler:

js
const handleClick = (e) => {
  fetch('http://localhost:8000/api/mail/login', {
    method: 'POST',
  }).then((res) => res.text());
};

And the Express route builds the Google URL with the googleapis OAuth client and redirects to it:

js
const url = oauth2Client.generateAuthUrl({
  access_type: 'offline',
  scope: process.env.SCOPE,
});
 
router.post('/login', (req, res, next) => {
  res.redirect(url);
});

The author had already added app.use(cors()) and the error did not change. That detail is the key to the whole problem.

Reproduced in Chromium and Firefox#

The setup was rebuilt on 22 October 2026 in the official Playwright v1.63.0 Docker image: a page served from http://localhost:3000, an Express 5.3.0 API on port 8000 with cors 2.8.6, and the same URL built by generateAuthUrl from google-auth-library 10.9.1 with a placeholder client ID. That is the same class the question uses: in googleapis, google.auth.OAuth2 is set to google-auth-library's OAuth2Client (googleapis-common source). A twin of the API on port 8001 has no cors(). The browsers were the ones bundled with that image, Chromium 153.0.8010.12 and Firefox 155.0. The full scripts and raw output are kept in the article's reproduction log.

The question's fetch against the API with cors(), in Chromium:

text
[chromium S1 fetch POST to API with cors() (the question) network] POST http://localhost:8000/api/mail/login -> 302 acao=* location=https://accounts.google.com/o/oauth2/v2/auth
[chromium S1 fetch POST to API with cors() (the question) console] Access to fetch at 'https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&scope=https%3A%2F%2Fmail.google.com%2F&response_type=code&client_id=example-client-id.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fcallback' (redirected from 'http://localhost:8000/api/mail/login') from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
[chromium S1 fetch POST to API with cors() (the question)] fetch rejected TypeError: Failed to fetch

That is the question's message, apart from the placeholder client ID, the redirect URI and the closing "If an opaque response serves your needs" hint, which the Chromium 153 build in this run did not print. The first line matters: your API answered 302 with Access-Control-Allow-Origin: *, so cors() did its job.

The same fetch against the API without cors():

text
[chromium S2 same fetch, API without cors() console] Access to fetch at 'http://localhost:8001/api/mail/login' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Now the blocked resource is your own API, and there is no (redirected from ...) clause. Adding cors() therefore did not fail: it got the first hop through and moved the error to the second hop, Google's page. Firefox shows the same two cases in its own words, and it also logged that Google answered the cross-origin fetch with a 403:

text
[firefox S1 fetch POST to API with cors() (the question) console] [JavaScript Error: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&scope=https%3A%2F%2Fmail.google.com%2F&response_type=code&client_id=example-client-id.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fcallback. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 403."]

Why a redirect inside fetch hits Google's CORS wall#

res.redirect(url) in Express sends a 302 by default (the Express source documents res.redirect as "defaulting to 302"). What happens next depends on who receives that 302.

When fetch() receives it, the browser follows the redirect inside the same request, still on behalf of your page at localhost:3000. The Fetch Standard's HTTP fetch algorithm runs the CORS check on each response before it handles the redirect: "If request's response tainting is "cors" and a CORS check for request and response returns failure, then return a network error." Your API's 302 passes that check thanks to cors(). Google's response is checked too, and it carries no Access-Control-Allow-Origin header, so the whole fetch becomes a network error. Your script sees only a generic TypeError: "Failed to fetch" in Chromium, "NetworkError when attempting to fetch resource." in Firefox.

Google says this plainly. Its guide to OAuth 2.0 for client-side web applications explains that "this OAuth 2.0 endpoint does not support Cross-Origin Resource Sharing (CORS)", which is why its own sample "creates a form that opens the request to that endpoint." Its server-side guide describes the same step as "Redirect the user to Google's OAuth 2.0 server", meaning the user's browser tab, not a background request.

There is also a design reason the fetch could never work, even without CORS. The sign-in page is meant to be seen: the user picks an account and approves the scopes. A response delivered to fetch() is just bytes handed to your script, never rendered. The accepted answer puts it as "The authentication flow must happen in a visible browsing context", and notes that when the tab is navigated, "none of the requests made is cross-origin, so no CORS will be involved at all."

One more detail visible in the network log: the POST became a GET at Google. The Fetch Standard's redirect rules switch the method to GET and drop the body when a 301 or 302 answers a POST, so the body of your original request never reaches Google either way.

For the general case of a CORS error on your own API (missing header, wildcard with credentials, preflight), see blocked by CORS policy from origin localhost:3000. This page is about the case where your API is already correct and the failing hop belongs to someone else.

Fix: let the browser tab go to Google#

All three options below were run in both browsers in the reproduction, and in each one the tab ended up on accounts.google.com. With a placeholder client ID, Google shows its own error page there; with real credentials, that is where the consent screen appears.

1. Submit a form to the login route#

This is the accepted answer's fix. Your router.post('/login', ...) route stays as it is:

jsx
export function GoogleLoginButton() {
  return (
    <form action="http://localhost:8000/api/mail/login" method="post">
      <button type="submit">Sign in with Google</button>
    </form>
  );
}

A form submission is a navigation. The tab posts to your API, receives the 302 and loads Google's page as a document. Reproduction output:

text
[chromium S5 fix: form POST navigates the tab] tab now at https://accounts.google.com/signin/oauth/error

2. Point window.location.href at a GET route#

If you prefer a plain button, expose the route as GET and navigate to it:

js
// Express
router.get('/login', (req, res) => {
  res.redirect(url);
});
js
// React
const handleClick = () => {
  window.location.href = 'http://localhost:8000/api/mail/login';
};
text
[firefox S7 fix: window.location.href to the GET route] tab now at https://accounts.google.com/signin/oauth/error

3. Fetch the URL as data, then navigate#

If the front end needs data from the API before it leaves the page, return the URL as JSON instead of redirecting, and let the client navigate:

js
// Express
router.get('/auth-url', (req, res) => {
  res.json({ url });
});
js
// React
const handleClick = async () => {
  const res = await fetch('http://localhost:8000/api/mail/auth-url');
  const { url } = await res.json();
  window.location.assign(url);
};

This fetch is fine because it only reads JSON from your own API, which cors() already allows. The trip to Google is a navigation.

Check your own blocked-by-CORS message#

If your console line looks different, paste it below with any response headers from DevTools. The tool covers CORS errors on your own API; for a redirect to a third-party page like Google's, the fix above applies. It runs in your browser; nothing is sent.

Everything runs in your browser — nothing you paste is sent anywhere, and tokens, cookies and passwords are masked in the output.

Optional: response headers (curl -i / curl -v output)
Try:

Verify the fix#

  1. Open DevTools, Network tab, tick "Preserve log", then click your sign-in control.
  2. The request to accounts.google.com should be of type document, not fetch, and the console should show no CORS line.
  3. The address bar should show accounts.google.com. After you approve, Google sends the tab back to your redirect URI with a code query parameter.
  4. Your server then exchanges that code for tokens in a server-to-server call (Google's "Step 5: Exchange authorization code for refresh and access tokens"). With the Node client that is oauth2Client.getToken(code). This exchange runs on your server, so CORS never applies to it. The consent screen and the code exchange were not part of the reproduction, which used a placeholder client ID; they follow Google's documentation.

If the tab now reaches Google but Google shows a redirect_uri_mismatch error, you have moved on to a configuration error, not a CORS one. Google's authorisation errors list defines it as "The redirect_uri passed in the authorization request does not match an authorized redirect URI for the OAuth client ID." The redirect URI in your code has to match one registered for the client exactly; the Supabase Google OAuth guide for Next.js 15 walks through which URIs go where when Supabase sits in the middle.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

What does not work, and why#

mode: 'no-cors'. The console hint suggests it, and it does silence the error, but it does not start a sign-in. In the reproduction the request completed with an empty, unreadable response, and the tab stayed on the app:

text
[chromium S3 mode: 'no-cors'] type=opaque status=0 url="" text="" page=http://localhost:3000/

The same opaque-response trap breaks API calls too; SyntaxError: Unexpected end of input with mode: 'no-cors' covers that side.

redirect: 'manual' to read the Location header. It looks like a way to grab Google's URL and navigate to it yourself, but the browser returns an opaque-redirect response that hides the header:

text
[chromium S4 redirect: 'manual'] type=opaqueredirect status=0 location=null

The Fetch Standard defines an opaque-redirect filtered response as having status 0 and an empty header list. Return the URL in the body instead (fix 3).

More CORS headers on your API. Your API's headers were already accepted. No header you send can make Google's response readable.

The same rule in Supabase and popup sign-in#

Supabase's client follows the navigation rule for you. In supabase-js, signInWithOAuth ends in _handleProviderSignIn, which calls window.location.assign(url) in the browser unless you pass skipBrowserRedirect: true (GoTrueClient source). If you set that option, you get back data.url and the job of sending the tab there is yours, by navigation as in fix 3, not by fetching it.

Popup sign-in is the other navigation-based pattern: the provider page opens in a new window instead of the current tab. That avoids CORS, but it has its own failure mode when a strict Cross-Origin-Opener-Policy cuts the link between the two windows. For Firebase popup sign-in that case, and the header change that fixes it, is covered in Cross-Origin-Opener-Policy blocks window.closed.

Whatever the stack, the rule from this error holds: OAuth authorisation pages are destinations for the browser tab, and a background request can only ever see them as a response it is not allowed to read.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote