MERN Stack

MERN Stack - User Authentication with OAuth 2.0

By the Domain India teamPublished 9 min read
Knowledge base article
Contents (9 sections)

"Sign in with Google" spares your users another password and spares you from storing one. This guide adds it to a MERN app (MongoDB, Express, React, Node.js) the 2026 way: the authorization code flow with PKCE, run on the server, with the login kept in an httpOnly session cookie that browser JavaScript cannot read.

Key takeaways

Let your Express server run the whole OAuth 2.0 exchange with Google using the openid-client library: authorization code flow, PKCE, state and nonce. After a successful login, create a server-side session and give the browser only an httpOnly, Secure, SameSite cookie. React never sees a token; it simply calls /api/me. Never put access or refresh tokens in localStorage.

1. How OAuth 2.0 and OpenID Connect fit together

OAuth 2.0 handles authorization: limited access to a user's account at a provider, without seeing their password. OpenID Connect (OIDC), built on top of it, adds authentication: an ID token that says who the user is. "Sign in with Google" is OIDC.

  • Authorization server: Google, which shows the login screen and issues codes and tokens.
  • Client: your Express server. It holds a client ID (public) and a client secret (private, server only).
  • Authorization code: a short-lived, one-time code Google sends to your callback URL.
  • ID token: a signed JWT with claims such as sub (the user's permanent ID), email and email_verified.
  • PKCE: a random code verifier proving that the client finishing the login is the one that started it. It is recommended for every client, including server-side ones.

2. Choose the right architecture

ApproachWhere tokens liveVerdict
Server-side login + session cookie (this guide)On the server; the browser holds only an httpOnly cookieRecommended for web apps
SPA does OAuth itself, stores tokens in localStorageIn JavaScript-readable storageAvoid: one XSS bug leaks every token
Your own JWT pair (short access token + rotating refresh token)Access token in memory, refresh token in an httpOnly cookieFine for mobile apps and separate APIs, but more to get right

This pattern is often called a backend for frontend: React and the API share one site, so the browser sends the cookie automatically.

3. Register your app with Google

  1. Create a project
    in Google Cloud Console and open Google Auth Platform (formerly the OAuth consent screen). Enter your app name, support email and authorised domain.
  2. Create an OAuth client
    of type Web application.
  3. Add redirect URIs
    exactly: http://localhost:5173/auth/google/callback for development and https://yourdomain.com/auth/google/callback for production.
  4. Copy the client ID and secret
    into a .env file that is listed in .gitignore. Never commit it.
bash
# .env (never commit this file)
GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=replace-me
APP_URL=http://localhost:5173
SESSION_SECRET=output-of: openssl rand -hex 48
MONGODB_URI=mongodb://127.0.0.1:27017/mern-auth

4. Set up the Express server

Use a current LTS release of Node.js (24 at the time of writing) and Express 5. The project uses ES modules, so add "type": "module" to package.json.

bash
mkdir mern-auth && cd mern-auth
npm init -y && npm pkg set type=module
npm install express express-session connect-mongo mongoose openid-client
javascript
// server/index.js  (run with: node --env-file=.env server/index.js)
import express from 'express';
import session from 'express-session';
import MongoStore from 'connect-mongo';
import mongoose from 'mongoose';
import authRoutes from './auth.js';
import { User } from './user.js';

await mongoose.connect(process.env.MONGODB_URI);
const app = express();
const isProd = process.env.NODE_ENV === 'production';

app.set('trust proxy', 1); // needed behind nginx or a platform proxy
app.use(express.json());
app.use(session({
  name: 'sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  store: MongoStore.create({ mongoUrl: process.env.MONGODB_URI, ttl: 7 * 24 * 3600 }),
  cookie: { httpOnly: true, secure: isProd, sameSite: 'lax', maxAge: 7 * 24 * 3600 * 1000 },
}));

app.use('/auth', authRoutes);

function requireUser(req, res, next) {
  if (!req.session.userId) return res.status(401).json({ error: 'Not signed in' });
  next();
}

app.get('/api/me', requireUser, async (req, res) => {
  const user = await User.findById(req.session.userId).select('name email picture role');
  if (!user) return res.status(401).json({ error: 'Not signed in' });
  res.json(user);
});

app.listen(process.env.PORT || 3000);

sameSite: 'lax' still sends the cookie when Google redirects back to you, but not on cross-site POST requests.

5. The user model

Identify users by the provider's sub claim, never by email: an email address can change, sub cannot.

javascript
// server/user.js
import mongoose from 'mongoose';

const userSchema = new mongoose.Schema({
  provider: { type: String, required: true },   // 'google'
  providerId: { type: String, required: true }, // the 'sub' claim
  email: String,
  name: String,
  picture: String,
  role: { type: String, enum: ['user', 'admin'], default: 'user' },
  lastLoginAt: Date,
}, { timestamps: true });

userSchema.index({ provider: 1, providerId: 1 }, { unique: true });
export const User = mongoose.model('User', userSchema);

6. The login routes: PKCE, state and nonce

openid-client (version 6) discovers Google's endpoints and validates everything that comes back: state, PKCE verifier, ID token signature, issuer, audience and nonce.

javascript
// server/auth.js
import express from 'express';
import * as client from 'openid-client';
import { User } from './user.js';

const router = express.Router();
const config = await client.discovery(
  new URL('https://accounts.google.com'),
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
);
const redirectUri = `${process.env.APP_URL}/auth/google/callback`;

router.get('/google', async (req, res) => {
  const codeVerifier = client.randomPKCECodeVerifier();
  const state = client.randomState();
  const nonce = client.randomNonce();
  req.session.oauth = { codeVerifier, state, nonce };

  const url = client.buildAuthorizationUrl(config, {
    redirect_uri: redirectUri,
    scope: 'openid email profile',
    code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
    code_challenge_method: 'S256',
    state,
    nonce,
  });
  res.redirect(url.href);
});

router.get('/google/callback', async (req, res, next) => {
  const pending = req.session.oauth;
  delete req.session.oauth;
  if (!pending) return res.redirect('/?login=expired');

  try {
    const tokens = await client.authorizationCodeGrant(
      config,
      new URL(req.originalUrl, process.env.APP_URL),
      { pkceCodeVerifier: pending.codeVerifier, expectedState: pending.state, expectedNonce: pending.nonce },
    );
    const claims = tokens.claims();
    if (!claims.email_verified) return res.redirect('/?login=unverified');

    const user = await User.findOneAndUpdate(
      { provider: 'google', providerId: claims.sub },
      { email: claims.email, name: claims.name, picture: claims.picture, lastLoginAt: new Date() },
      { upsert: true, new: true, setDefaultsOnInsert: true },
    );

    // New session ID after login prevents session fixation
    req.session.regenerate((err) => {
      if (err) return next(err);
      req.session.userId = user.id;
      res.redirect('/');
    });
  } catch (err) {
    next(err);
  }
});

router.post('/logout', (req, res, next) => {
  req.session.destroy((err) => {
    if (err) return next(err);
    res.clearCookie('sid').status(204).end();
  });
});

export default router;

The rebuilt callback URL must match the registered redirect URI exactly. Logout is a POST, so another site cannot sign your users out with a link. And sign-in needs no stored Google tokens; keep them only if you call Google APIs, encrypted at rest.

7. The React front end

Create the client with Vite (Create React App is deprecated):

bash
npm create vite@latest client -- --template react

In development, let Vite forward /api and /auth to Express so that everything is on one origin:

javascript
// client/vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: { proxy: { '/api': 'http://localhost:3000', '/auth': 'http://localhost:3000' } },
});
jsx
// client/src/App.jsx
import { useEffect, useState } from 'react';
const LOGIN_URL = '/auth/google';

export default function App() {
  const [user, setUser] = useState(undefined);

  useEffect(() => {
    fetch('/api/me').then((r) => (r.ok ? r.json() : null)).then(setUser);
  }, []);

  async function logout() {
    await fetch('/auth/logout', { method: 'POST' });
    setUser(null);
  }

  if (user === undefined) return <p>Loading…</p>;
  if (!user) return <a href={LOGIN_URL}>Sign in with Google</a>;
  return (
    <main>
      <img src={user.picture} alt="" width="48" height="48" />
      <h1>Welcome, {user.name}</h1>
      <button onClick={logout}>Sign out</button>
    </main>
  );
}

The login is a normal link, not a fetch, because the browser must navigate to Google.

8. Roles, more providers and production checks

  • Roles: check role on the server in a middleware such as requireAdmin. Hiding a button in React is not access control. Set the first admin by hand in the database; never let a sign-up request choose a role.
  • More providers: Microsoft and other OIDC providers work the same way. GitHub is plain OAuth 2.0, so fetch the profile from its API after the code exchange.
  • Before going live: serve only over HTTPS, set NODE_ENV=production so the cookie is Secure, rate-limit /auth, add security headers (for example with helmet), run npm audit, and publish the Google app out of Testing.
  • Common errors: redirect_uri_mismatch means the callback differs from the registered URI, even by a slash or http against https. A login that loops back signed out usually means the cookie was not set: check trust proxy and HTTPS.

9. Running this on Domain India

  • App Platform: Node.js apps are detected and built automatically; deploy from GitHub with Deploy Now or with a deploy token. The included database is PostgreSQL, not MongoDB, so store users and sessions there (for example with connect-pg-simple) or use an external MongoDB service; if it has an IP allow list, ask support which address your app connects from. Listen on PORT and bind to 0.0.0.0. See Getting started with the App Platform.
  • VPS: a VPS gives you full root access and is self-managed: you install Node.js, MongoDB, nginx and a TLS certificate yourself, and there is no cPanel. See Running MERN/MEAN on a clean VPS.
  • Shared hosting: cPanel and DirectAdmin run small Node.js apps through the panel's Node.js tool (guide); MongoDB is not provided there.
App Starter
₹100/mo + GST
  • 512 MB RAM per app
  • 1 vCPU
  • 5 GB NVMe SSD
  • PostgreSQL Database
See plan details
VPS Starter
₹552.65/mo + GST
  • 1 vCPU
  • 2 GB DDR4 RAM
  • 64 GB NVMe SSD Storage
  • 2 TB Monthly Bandwidth
See plan details
Is OAuth 2.0 the same as "Sign in with Google"?

Not quite. OAuth 2.0 grants access to resources; OpenID Connect, built on top of it, adds an ID token that identifies the user. "Sign in with Google" uses OpenID Connect over the OAuth 2.0 authorization code flow.

Do I need PKCE if my server has a client secret?

Yes, it is recommended. PKCE binds the authorization code to the login that started it, which blocks code-injection attacks even for confidential server-side clients, and Google supports it.

Should I store the access token in localStorage?

No. Anything in localStorage can be read by any script on the page, so a single cross-site scripting bug leaks it. Keep tokens on the server and give the browser an httpOnly session cookie.

Which field should identify a user?

The provider's sub claim together with the provider name. Email addresses can change or be reassigned; sub is permanent for that account.

Can I run this app on the Domain India App Platform?

Yes. Node.js apps are detected automatically. The included database is PostgreSQL, so store users and sessions there or connect to an external MongoDB service.

Ready to deploy? Compare App Platform plans, look at a VPS if you want full control, or ask us in a support ticket which fits your app.

Deploy your Node.js app

Node.js apps are detected and built automatically, with PostgreSQL and free SSL included in every plan.

See App Platform plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app