Sign in With Google in Node.js — Without Passport
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.
Senior Developer

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 & Services → Credentials → Create Credentials → OAuth 2.0 Client ID
Application type: Web application
Authorized Redirect URIs:
http://localhost:3000/auth/google/callbackCopy 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 < 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() > 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) => {
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=<reason>. 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 () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
});
}
test("exchangeCode throws on non-200 response", async () => {
await assert.rejects(
() => exchangeCode("code", "verifier", mockFetch(400, { error: "invalid_grant" })),
/Token exchange failed/
);
});
test("fetchUserInfo throws if email is not verified", async () => {
await assert.rejects(
() => 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", () => {
const realNow = Date.now;
const state = createState("verifier");
Date.now = () => 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=...&state=...&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=<paste-your-jwt-here>" 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=<paste-your-jwt-here>" \
-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
Comments (0)
Login to post a comment.