Google OAuth works in production but shows “origin_mismatch” error in development (localhost)

I’m setting up Google OAuth 2.0 for my app, and I’m encountering an issue where everything works perfectly in production (https://mywebsite.com), but in development (localhost), I get an “Error 400: origin_mismatch” error during the Google sign-in process.

Error Message

The error displayed in the Google sign-in modal is:

Access blocked: Authorization Error

[email protected]

You can't sign in to this app because it doesn't comply with Google's OAuth 2.0 policy.

If you're the app developer, register the JavaScript origin in the Google Cloud Console.
Error 400: origin_mismatch

Setup Details

  • Frontend: localhost:3000 (React)
  • Backend: localhost:8000 (Express with Node.js)

Google Cloud Console Configuration

I’ve configured my Google Cloud Console with the following:

  • Authorized JavaScript Origins:

    • http://localhost:3000
    • https://mywebsite.com (for production)
  • Authorized redirect URIs:

    • http://localhost:8000/api/callback (for development)
    • https://mywebsite.com/api/callback (for production)

Relevant Code

Backend OAuth Route (Express.js in auth.js file):

const express = require("express");
const router = express.Router();
const oauth2Client = require("../config/googleConfig");
const SCOPES = [
    'https://www.googleapis.com/auth/userinfo.profile',
    'https://www.googleapis.com/auth/userinfo.email'
];

// Redirect to Google's OAuth 2.0 server to initiate authentication
router.get('/google', (req, res) => {
    console.log(`/google called`);
    const authUrl = oauth2Client.generateAuthUrl({
        access_type: 'offline',
        scope: SCOPES,
        redirect_uri: 'http://localhost:8000/api/callback' // Explicitly set redirect URI for development
    });
    res.redirect(authUrl);
});

// Handle the OAuth 2.0 server response
router.get('/callback', async (req, res) => {
    console.log(`/callback req.query: `, req.query);
    const { code } = req.query;
    try {
        const { tokens } = await oauth2Client.getToken(code);
        oauth2Client.setCredentials(tokens);
        req.session.tokens = tokens;
        res.redirect('http://localhost:3000'); // Redirect back to frontend after successful login
    } catch (error) {
        console.error('Error during OAuth callback:', error.response ? error.response.data : error);
        res.status(500).send('Authentication failed');
    }
});

module.exports = router;

CORS Configuration (Express middleware in server.js):

// process.env.CLIENT_URL === http://localhost:3000

app.use(cors({ 
  origin: `${process.env.CLIENT_URL}`,
  credentials: true
}));

Session Configuration (Express express-session setup):

app.use(session({
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    store: MongoStore.create({ mongoUrl: process.env.DATABASE }),
    cookie: {
        maxAge: 2 * 60 * 60 * 1000, // 2 hours
        httpOnly: true,
        secure: false, // Set to false in development
        sameSite: 'Lax' // Ensures cookies are accessible across localhost:3000 and localhost:8000
    }
}));

And now for the frontend code that is used to communicate with the backend.

Frontend React API Call function

export const googleSignIn = async (token) => { // not the JWT token, the google token
  try {
      const response = await fetch(`${API}/google-login`, {
          method: 'POST',
          headers: {
              Accept: 'application/json',
              'Content-Type': 'application/json',
          },
          credentials: 'include', // Ensure session cookie is included
          body: JSON.stringify({ idToken: token }),
      });
      return await response.json();
  } catch (err) {
      return { error: 'Google sign-in failed. Please try again.' };
  }
};

What I’ve Tried

  1. Verified Authorized JavaScript Origins and Redirect URIs: Double-checked that http://localhost:3000 is set as an authorized JavaScript origin and http://localhost:8000/api/callback as an authorized redirect URI in the Google Cloud Console for development.

  2. Explicitly Set Redirect URI in generateAuthUrl: Ensured that the redirect_uri is specified directly in the generateAuthUrl call to match what’s in the Google Console.

  3. Adjusted SCOPES: Initially, I used 'https://www.googleapis.com/auth/drive.file', but I changed it to:

    const SCOPES = [
        'https://www.googleapis.com/auth/userinfo.profile',
        'https://www.googleapis.com/auth/userinfo.email'
    ];
    

    This change was made to avoid potential scope conflicts, as I only need basic user info for authentication.

  4. Cleared Cache and Cookies: Cleared browser cache and cookies on localhost, and also tested in incognito mode to eliminate any caching issues.

  5. Console Logs and Network Activity: Inspected network logs in the browser developer tools. I verified that the authUrl generated in the /google route matches the redirect URI set in the Google Console.

Observed Behavior

  • Production: Works without issues on https://mywebsite.com, with users able to log in and authenticate.
  • Development: Returns “origin_mismatch” error during the last step of the Google OAuth process in the sign-in modal.

Question

Why is Google OAuth throwing an “origin_mismatch” error only in the development environment? Are there specific configurations for localhost that I might be overlooking? Any advice on how to fix this issue would be greatly appreciated!