Skip to content
Security Beginner Tutorial

Add Google Sign-In to a Node.js App with OpenID Connect

Wire up Sign in with Google using OIDC discovery, verified ID tokens, and secure session cookies.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Jul 22, 2026 · 4 min read
Add Google Sign-In to a Node.js App with OpenID Connect

What you'll build

A minimal Express app with a working "Sign in with Google" button: the full OpenID Connect authorization code flow with PKCE, automatic ID token verification via openid-client, and the user stored in a secure session cookie.

Prerequisites

  • Node.js 22 or 24 LTS (openid-client v6 needs ^20.19.0, ^22.12.0, or >=23; we also use the built-in --env-file flag)
  • A Google account and a Google Cloud project (free, no billing needed)
  • Verified against: openid-client 6.8.4, express 5.2.1, express-session 1.19.0

1. Create Google OAuth credentials

  1. Open the Google Auth Platform Clients page in the Cloud console. If this is the project's first OAuth client, you'll be prompted to configure branding first: set an app name and support email, choose External audience, and save.
  2. Click Create client, choose Web application as the application type.
  3. Under Authorized redirect URIs, add exactly http://localhost:3000/auth/callback — Google matches this string character-for-character.
  4. Click Create, then copy the client ID and client secret. The secret is only shown once, so store it now.
  5. While your app is in Testing mode, go to Audience and add your own Google account under Test users — only listed testers can sign in.

2. Scaffold the project

mkdir google-oidc-login && cd google-oidc-login
npm init -y
npm pkg set type=module
npm install express@5.2.1 express-session@1.19.0 openid-client@6.8.4

type=module enables ESM imports and top-level await, which openid-client v6 is built around.

Create .env with your credentials (never commit this file):

GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
SESSION_SECRET=paste-output-of: openssl rand -hex 32

3. Write the server

Create server.js:

import express from 'express'
import session from 'express-session'
import * as client from 'openid-client'

// Fetches https://accounts.google.com/.well-known/openid-configuration once at
// boot — endpoints and signing keys come from the discovery document, not hardcoded.
const config = await client.discovery(
  new URL('https://accounts.google.com'),
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
)

const redirect_uri = 'http://localhost:3000/auth/callback'
const app = express()

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  // sameSite 'lax' still sends the cookie on Google's top-level redirect back;
  // set secure: true once you're behind HTTPS in production.
  cookie: { httpOnly: true, sameSite: 'lax', secure: false },
}))

app.get('/', (req, res) => {
  if (req.session.user) {
    const { name, email } = req.session.user
    res.send(`<p>Signed in as ${name} (${email})</p><a href="/logout">Sign out</a>`)
  } else {
    res.send('<a href="/auth/login">Sign in with Google</a>')
  }
})

app.get('/auth/login', async (req, res) => {
  const code_verifier = client.randomPKCECodeVerifier()
  const code_challenge = await client.calculatePKCECodeChallenge(code_verifier)
  const state = client.randomState()

  // Stashed server-side so the callback can prove this response matches our request
  req.session.code_verifier = code_verifier
  req.session.state = state

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

app.get('/auth/callback', async (req, res) => {
  try {
    const currentUrl = new URL(req.originalUrl, `http://${req.get('host')}`)
    // Exchanges the code AND verifies the ID token: signature (via jwks_uri),
    // issuer, audience, and expiry — plus the PKCE verifier and state checks.
    const tokens = await client.authorizationCodeGrant(config, currentUrl, {
      pkceCodeVerifier: req.session.code_verifier,
      expectedState: req.session.state,
    })
    const claims = tokens.claims()
    // Fresh session ID after login prevents session fixation
    req.session.regenerate((err) => {
      if (err) return res.status(500).send('Session error')
      // `sub` is the stable unique user ID — never key users on email
      const { sub, name, email, picture } = claims
      req.session.user = { sub, name, email, picture }
      res.redirect('/')
    })
  } catch (err) {
    console.error(err)
    res.status(401).send('Sign-in failed')
  }
})

app.get('/logout', (req, res) => {
  req.session.destroy(() => res.redirect('/'))
})

app.listen(3000, () => console.log('Listening on http://localhost:3000'))

Verify it works

node --env-file=.env server.js

Expected output:

Listening on http://localhost:3000

Open http://localhost:3000, click Sign in with Google, pick your test-user account, and approve the consent screen. You should land back on the homepage showing:

Signed in as Your Name (you@gmail.com)

Click Sign out and confirm the sign-in link returns. In DevTools → Application → Cookies, the connect.sid cookie should show HttpOnly ✓ and SameSite: Lax.

Troubleshooting

Error 400: redirect_uri_mismatch on Google's consent page — the redirect URI in your code doesn't exactly match the one registered in the console. Check for a missing /auth/callback path, a different port, https vs http, or a trailing slash. Edit the client on the Clients page; changes can take a few minutes to propagate.

Error 403: access_denied — "app is currently being tested" — your app is in Testing mode and the Google account you picked isn't a test user. Add it under Google Auth Platform → Audience → Test users.

SyntaxError: Cannot use import statement outside a module — the import lines need ESM. Run npm pkg set type=module (step 2) or rename the file to server.mjs.

unexpected "state" response parameter encountered thrown in the callback — your session was empty when Google redirected back, so expectedState was undefined. Usually the session cookie wasn't saved: make sure you started the flow from http://localhost:3000 (not 127.0.0.1, which is a different cookie origin), and that secure isn't true on plain HTTP.

Next steps

  • Read Google's OpenID Connect guide for the full list of ID token claims and validation rules.
  • Persist users keyed on sub in a database, and swap express-session's default in-memory store (it leaks memory and resets on restart) for connect-redis or a database-backed store before production.
  • Add token refresh with client.refreshTokenGrant() if you request access_type=offline to call Google APIs on the user's behalf.
  • The openid-client docs cover logout via RP-initiated logout, JAR/PAR, and other providers — the same code works with any OIDC-compliant issuer.

Sources & further reading

  1. OpenID Connect - Google Identity — developers.google.com
  2. openid-client - OAuth 2 / OpenID Connect Client for JavaScript Runtimes — github.com
  3. Manage OAuth Clients - Google Cloud Platform Console Help — support.google.com
  4. authorizationCodeGrant - openid-client v6 docs — github.com
  5. Express - Node.js web application framework — expressjs.com
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading