{"schemaVersion":"1.0","type":"Article","slug":"sign-in-with-google-in-node-js-without-passport-urmsv","url":"https://api.zyvop.com/sign-in-with-google-in-node-js-without-passport-urmsv","title":"Sign in With Google in Node.js — Without Passport","subtitle":"Build Google OAuth from first principles using PKCE, state validation, secure token exchange, refresh tokens, and JWT sessions in Node.js—without Passport.js or authentication libraries.","tldr":"Learn how to implement Google Sign-In in Node.js without Passport.js by building the complete OAuth 2.0 flow yourself. This guide covers PKCE, CSRF protection with state, token exchange, refresh tokens, secure session handling, production hardening, and comprehensive testing with plain Node.js.","keywords":["Google OAuth","OAuth 2.0","PKCE","Passport.js","authentication","jwt","Web Security","nodejs"],"entities":["ZyVOP","Google OAuth","OAuth 2.0","PKCE","Passport.js","authentication","jwt","Web Security","nodejs"],"keyTakeaways":["Most \"Sign in with Google\" tutorials hand you Passport.js and a dozen middleware functions, then stop before explaining what any of them do.","When the OAuth dance fails in production — wrong redirect URI, missing refresh token, expired state — you're left debugging a black box.","This post builds the whole flow from scratch: PKCE code generation, the authorization redirect, the callback handler that validates everything before touching the database, token exchange, and refresh token handling."],"headings":["Setup","Why PKCE, and why does it matter for server-side apps","CSRF protection: the state parameter","Building the authorization URL","The callback: three validations before anything else","Token exchange and userinfo","Storing users and handling refresh tokens","The sign-in page","Testing without a Google account","Trying it with curl","Before going to production"],"outboundLinks":["https://zyvop.com/implementing-two-factor-authentication-totp-in-nestjs-with-full-source-code-xg82n","https://zyvop.com/passwordless-login-with-magic-links-in-node-js-2r287","https://github.com/zyvop27-cmyk/zyvop-blogs/tree/master/oauth-google","https://console.cloud.google.com/"],"contentText":"Most \"Sign in with Google\" tutorials hand you Passport.js and a dozen middleware functions, then stop before explaining what any of them do. When the OAuth dance fails in production — wrong redirect URI, missing refresh token, expired state — you're left debugging a black box. This post builds the whole flow from scratch: PKCE code generation, the authorization redirect, the callback handler that validates everything before touching the database, token exchange, and refresh token handling. No Passport, no auth library. Just four endpoints and the Google API. This is the third post in Zyvop's auth series. If you haven't read the 2FA implementation or the magic link post yet, the session module here is the same one from those — a JWT in an httpOnly cookie. The new parts are everything that happens before you issue that cookie. Source: https://github.com/zyvop27-cmyk/zyvop-blogs/tree/master/oauth-google Setup git clone [YOUR_GITHUB_REPO_URL] cd oauth-google npm install cp .env.example .env Then in Google Cloud Console: APIs &amp; Services → Credentials → Create Credentials → OAuth 2.0 Client ID Application type: Web application Authorized Redirect URIs: http://localhost:3000/auth/google/callback Copy the Client ID and Secret into .env Generate a JWT_SECRET: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\" npm start # open http://localhost:3000 Why PKCE, and why does it matter for server-side apps PKCE (Proof Key for Code Exchange) was designed for mobile and single-page apps that can't keep a client secret truly secret. For server-side apps you already have a secret, so adding PKCE is belt-and-suspenders. Google now requires it for all new OAuth clients regardless of app type, so it's no longer optional — it's just how OAuth with Google works in 2026. The mechanism: before sending the user to Google, you generate a random code_verifier, hash it to produce a code_challenge, and send the challenge to Google. When the user comes back with an authorization code, you exchange the code by also sending the original verifier. Google hashes it and checks it matches the challenge it stored. Anyone who intercepts the authorization code without the original verifier can't use it. // src/lib/pkce.js import { randomBytes, createHash } from \"node:crypto\"; export function generateVerifier() { // 32 bytes → 43-char URL-safe base64, no padding (RFC 7636) return randomBytes(32).toString(\"base64url\"); } export function deriveChallenge(verifier) { return createHash(\"sha256\").update(verifier).digest(\"base64url\"); } export function verifyChallenge(verifier, expectedChallenge) { if (typeof verifier !== \"string\" || verifier.length &lt; 43) return false; return deriveChallenge(verifier) === expectedChallenge; } base64url is the base64 alphabet with + → -, / → _, and all = padding stripped. Node has it built in since v14 — no library needed. CSRF protection: the state parameter The state parameter is how you prevent cross-site request forgery on the callback. You generate a random value, send it to Google, and Google sends it back in the callback. You verify it matches what you sent before touching anything else. The state also carries the PKCE verifier. When the user lands on the callback, you need the verifier to complete the token exchange — but you can't put it in the URL, since that exposes it to server logs and referrer headers. You could store it in a signed httpOnly cookie, but that means setting an additional cookie before the redirect and cleaning it up on callback. The state parameter is a neater single-point solution: one random value carries both the CSRF token and the verifier retrieval key. // src/lib/state.js import { randomBytes } from \"node:crypto\"; const _store = new Map(); const STATE_TTL_MS = 10 * 60 * 1000; export function createState(verifier) { const state = randomBytes(24).toString(\"hex\"); _store.set(state, { verifier, expiresAt: Date.now() + STATE_TTL_MS }); return state; } export function consumeState(state) { if (typeof state !== \"string\" || !state) return null; const entry = _store.get(state); _store.delete(state); // delete before returning — no replay window if (!entry) return null; if (Date.now() &gt; entry.expiresAt) return null; return entry.verifier; } consumeState deletes the entry before checking it — not after. The order matters: if you check first and delete second, two simultaneous requests with the same state both pass the check before either deletes. This way only one gets the verifier back; the other gets null. The in-memory Map works fine for a single server process. For multiple instances behind a load balancer, move it to Redis with a 10-minute TTL. Building the authorization URL // src/lib/google.js export function buildAuthUrl() { const verifier = generateVerifier(); const challenge = deriveChallenge(verifier); const state = createState(verifier); const params = new URLSearchParams({ client_id: process.env.GOOGLE_CLIENT_ID, redirect_uri: process.env.GOOGLE_REDIRECT_URI, response_type: \"code\", scope: \"openid email profile\", state, code_challenge: challenge, code_challenge_method: \"S256\", access_type: \"offline\", // ask for a refresh token prompt: \"consent\", // force consent screen so refresh token is always issued }); return { url: `https://accounts.google.com/o/oauth2/v2/auth?${params}` }; } access_type: \"offline\" tells Google you want a refresh token. Without it, you only get an access token that expires in an hour, and the user has to sign in again. prompt: \"consent\" forces the consent screen on every sign-in, which is the only reliable way to get a refresh token every time — Google silently skips it on returning users unless you force it. The callback: three validations before anything else When Google redirects the user back, the callback handler does three things before touching the database: // src/routes/auth.js router.get(\"/google/callback\", async (req, res) =&gt; { const { code, state, error } = req.query; if (error) return res.redirect(\"/?error=access_denied\"); if (!code || !state) return res.redirect(\"/?error=invalid_callback\"); const verifier = consumeState(state); // validates + consumes in one step if (!verifier) return res.redirect(\"/?error=invalid_state\"); try { const { accessToken, refreshToken } = await exchangeCode(code, verifier); const userInfo = await fetchUserInfo(accessToken); const user = upsertUser(db, userInfo, refreshToken); issueSession(res, { sub: user.google_sub, email: user.email, name: user.name, picture: user.picture }); res.redirect(\"/dashboard\"); } catch (err) { console.error(\"[oauth] callback failed:\", err.message); res.redirect(\"/?error=auth_failed\"); } }); Check error first — this is what Google sends when the user clicks \"Cancel\". Then check code and state are present. consumeState in one call validates the state is real, not expired, and not already used, and returns the PKCE verifier. Only if all three pass do you make any network requests. Token exchange and userinfo The token exchange sends the authorization code plus the PKCE verifier to Google's token endpoint: export async function exchangeCode(code, verifier, fetchFn = fetch) { const res = await fetchFn(\"https://oauth2.googleapis.com/token\", { method: \"POST\", headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" }, body: new URLSearchParams({ code, client_id: process.env.GOOGLE_CLIENT_ID, client_secret: process.env.GOOGLE_CLIENT_SECRET, redirect_uri: process.env.GOOGLE_REDIRECT_URI, grant_type: \"authorization_code\", code_verifier: verifier, }), }); if (!res.ok) { const err = await res.text(); throw new Error(`Token exchange failed (${res.status}): ${err}`); } const data = await res.json(); return { accessToken: data.access_token, refreshToken: data.refresh_token ?? null, idToken: data.id_token, expiresIn: data.expires_in, }; } Google's response includes an id_token — a JWT containing the user's identity claims. You could decode and verify it locally using Google's published JWK keys, which avoids a second network request. This post uses the /userinfo endpoint instead, because it always returns current data and sidesteps implementing RS256 signature verification. For high-throughput production code, verify the id_token locally. The userinfo call is straightforward: export async function fetchUserInfo(accessToken, fetchFn = fetch) { const res = await fetchFn(\"https://www.googleapis.com/oauth2/v3/userinfo\", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok) throw new Error(`Userinfo fetch failed (${res.status})`); const data = await res.json(); if (!data.email_verified) throw new Error(\"Google account email is not verified\"); return { sub: data.sub, email: data.email, name: data.name ?? null, picture: data.picture ?? null, emailVerified: data.email_verified }; } The email_verified check matters. Google allows accounts with unverified email addresses. Silently accepting one would let someone claim ownership of an email they don't control. Storing users and handling refresh tokens Users are keyed by Google's sub (subject), not email. Email addresses can change; sub is permanent for a given Google account. The upsert uses COALESCE to preserve the existing refresh token when the new one is null: db.prepare(` INSERT INTO users (google_sub, email, name, picture, refresh_token) VALUES (@sub, @email, @name, @picture, @refreshToken) ON CONFLICT (google_sub) DO UPDATE SET email = excluded.email, name = excluded.name, picture = excluded.picture, refresh_token = COALESCE(excluded.refresh_token, users.refresh_token), last_login = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') RETURNING * `).get({ sub, email, name, picture, refreshToken: refreshToken ?? null }); Google only sends a refresh token on first sign-in (or after the user revokes access). If you overwrite the stored refresh token with null on subsequent logins, you lose the ability to refresh the user's access token without asking them to sign in again. COALESCE keeps the old one when the new one is absent. When the access token expires and you need a new one: export async function refreshAccessToken(refreshToken, fetchFn = fetch) { const res = await fetchFn(\"https://oauth2.googleapis.com/token\", { method: \"POST\", headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" }, body: new URLSearchParams({ client_id: process.env.GOOGLE_CLIENT_ID, client_secret: process.env.GOOGLE_CLIENT_SECRET, refresh_token: refreshToken, grant_type: \"refresh_token\", }), }); if (!res.ok) { const err = await res.text(); throw new Error(`Token refresh failed (${res.status}): ${err}`); } const data = await res.json(); return { accessToken: data.access_token, expiresIn: data.expires_in }; } A 400 response from this endpoint usually means the refresh token was revoked — the user removed your app from their Google account. Handle it by clearing the session and prompting a fresh sign-in. The sign-in page public/index.html is a single static page with a \"Continue with Google\" button that links to /auth/google. The interesting part is the error handling — when the OAuth flow fails for any reason, the server redirects back to /?error=&lt;reason&gt;. The page reads that query param and shows a human-readable message: const ERRORS = { access_denied: \"You cancelled the sign-in. Try again when you're ready.\", invalid_state: \"Something went wrong with the sign-in flow. Please try again.\", invalid_callback: \"The callback from Google was malformed. Please try again.\", auth_failed: \"Sign-in failed. Please try again or contact support.\", config: \"The server is misconfigured. Contact the site owner.\", default: \"Something went wrong. Please try again.\" }; const params = new URLSearchParams(location.search); const errKey = params.get(\"error\"); if (errKey) { const box = document.getElementById(\"error-box\"); box.textContent = ERRORS[errKey] ?? ERRORS.default; box.classList.add(\"visible\"); } Each error key maps to a specific failure point in the callback handler — access_denied when the user clicks Cancel, invalid_state when the state check fails, auth_failed when the token exchange or userinfo call throws. This matters: a generic \"something went wrong\" on a sign-in page sends users nowhere. Mapping errors to actionable messages costs five lines. Testing without a Google account Every external call takes an injectable fetchFn parameter. Tests pass a mock instead: function mockFetch(status, body) { return async () =&gt; ({ ok: status &gt;= 200 &amp;&amp; status &lt; 300, status, json: async () =&gt; body, text: async () =&gt; JSON.stringify(body), }); } test(\"exchangeCode throws on non-200 response\", async () =&gt; { await assert.rejects( () =&gt; exchangeCode(\"code\", \"verifier\", mockFetch(400, { error: \"invalid_grant\" })), /Token exchange failed/ ); }); test(\"fetchUserInfo throws if email is not verified\", async () =&gt; { await assert.rejects( () =&gt; fetchUserInfo(\"token\", mockFetch(200, { sub: \"123\", email: \"unverified@example.com\", email_verified: false })), /email is not verified/ ); }); The state store test exercises expiry by monkey-patching Date.now — no timers, no waiting: test(\"expired state entries are not returned\", () =&gt; { const realNow = Date.now; const state = createState(\"verifier\"); Date.now = () =&gt; realNow() + 11 * 60 * 1000; // 11 minutes later try { assert.equal(consumeState(state), null); } finally { Date.now = realNow; } }); npm test # tests 31 · pass 31 · fail 0 Trying it with curl Once the server is running with real Google credentials: Hit the sign-in page: curl -I http://localhost:3000/auth/google HTTP/1.1 302 Found Location: https://accounts.google.com/o/oauth2/v2/auth?client_id=...&amp;state=...&amp;code_challenge=... The OAuth flow goes through a browser — there's no way to automate it with curl end-to-end. To check the API after signing in, copy your session cookie from browser DevTools (Application → Cookies) and pass it directly: curl -H \"Cookie: session=&lt;paste-your-jwt-here&gt;\" http://localhost:3000/api/me { \"sub\": \"1234567890\", \"email\": \"you@gmail.com\", \"name\": \"Your Name\", \"picture\": \"https://lh3.googleusercontent.com/...\" } Refresh the access token (stored server-side): curl -H \"Cookie: session=&lt;paste-your-jwt-here&gt;\" \\ -X POST http://localhost:3000/auth/refresh { \"accessToken\": \"ya29.new-token\", \"expiresIn\": 3599 } No active session: curl http://localhost:3000/api/me { \"error\": \"Not authenticated.\" } Before going to production Move the state store to Redis — the in-memory Map disappears on restart and won't work across multiple server instances. The interface is the same, just backed by Redis keys with a 10-minute TTL. Set NODE_ENV=production to enable the Secure flag on the session cookie, which browsers require over HTTPS. Add your production domain to Authorized Redirect URIs in Google Cloud Console — that list is an exact-match allowlist, and the callback will silently fail with redirect_uri_mismatch if your domain isn't on it. If your threat model requires it, encrypt the refresh_token column at rest before writing to SQLite. A leaked database shouldn't hand an attacker long-lived access to user Google accounts. Get the code: https://github.com/zyvop27-cmyk/zyvop-blogs/tree/master/oauth-google","contentHash":"sha256:ccbe65f99b1c9ecd6485015043f1a668e542fab81dacfb7291e9d237a8f365f9","authorName":"ZyVOP","authorUrl":"https://api.zyvop.com/author/zyvop","authorSameAs":["https://zyvop.com","https://github.com/zyvop","https://x.com/zyvop1"],"category":null,"tags":["Google OAuth","OAuth 2.0","PKCE","Passport.js","authentication","jwt","Web Security","nodejs"],"audience":"Readers researching Google OAuth","tone":"Practical and evidence-based","readingTimeMinutes":9,"wordCount":2086,"faqs":null,"primaryTopic":"Google OAuth","publishedAt":"2026-07-18T06:00:03.175Z","updatedAt":"2026-08-30T23:55:00.120Z","canonicalUrl":"https://api.zyvop.com/sign-in-with-google-in-node-js-without-passport-urmsv"}