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.
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-fileflag) - 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
- 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.
- Click Create client, choose Web application as the application type.
- Under Authorized redirect URIs, add exactly
http://localhost:3000/auth/callback— Google matches this string character-for-character. - Click Create, then copy the client ID and client secret. The secret is only shown once, so store it now.
- 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
subin a database, and swap express-session's default in-memory store (it leaks memory and resets on restart) forconnect-redisor a database-backed store before production. - Add token refresh with
client.refreshTokenGrant()if you requestaccess_type=offlineto 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
- OpenID Connect - Google Identity — developers.google.com
- openid-client - OAuth 2 / OpenID Connect Client for JavaScript Runtimes — github.com
- Manage OAuth Clients - Google Cloud Platform Console Help — support.google.com
- authorizationCodeGrant - openid-client v6 docs — github.com
- Express - Node.js web application framework — expressjs.com
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
No comments yet
Be the first to weigh in.