ZyVOP Logo
Content That Connects
SeriesAI NewsCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article
  • Newsletter

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeSign in With Google in Node.js — Without Passport
👍1

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.

#Google OAuth#Node.js#OAuth 2.0#PKCE#Passport.js#authentication#jwt#Web Security
Z
ZyVOP

Senior Developer

July 18, 2026
8 min read
3 views
Sign in With Google in Node.js — Without Passport

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:

  1. APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID

  2. Application type: Web application

  3. Authorized Redirect URIs: http://localhost:3000/auth/google/callback

  4. 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 < 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

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Related Posts

Four Ways a Refresh Token Request Fails — Only One Means Trouble

A refresh token system where every token is single-use and belongs to a token family. Invalid, expired, and revoked are routine. Reuse means a stolen token was presented, so the entire session chain is revoked. Built with NestJS and tested on Postgres, proving valid refresh tokens die while issued access tokens remain valid.

Read article

Rate Limiting Alone Won't Stop a Patient Attacker

IP-based throttling and account-based lockout solve different problems and neither substitutes for the other. Built and tested in NestJS: the exact response shapes @nestjs/throttler returns, a TTL interaction that silently re-locks an account the moment it unlocks, and why an email needs to be normalized before it becomes a Redis key.

Read article

Background Jobs in NestJS with BullMQ: A Complete Walkthrough

A from-scratch background job pipeline in NestJS using BullMQ and Redis, demonstrated on an async LLM draft-generation endpoint — covering retry/backoff configuration, the off-by-one in BullMQ's attemptsMade, durable status records, and the idempotency behavior of duplicate job IDs.

Read article

Implementing Two-Factor Authentication (TOTP) in NestJS: With Full Source Code

A from-scratch implementation of TOTP-based two-factor authentication in NestJS, covering secret generation, QR code enrollment, partial vs. full JWTs, and single-use backup codes — with the complete source available to clone and run.

Read article

IDOR Vulnerabilities in NestJS: How to Build Ownership Guards That Actually Protect Your Data

IDOR is OWASP's top API risk for a reason. A single missing ownership check can expose customer data across your entire application. This guide shows how IDOR vulnerabilities appear in NestJS APIs, how to implement robust authorization guards, and how to verify your protections with practical security tests.

Read article

Popular Tags

#.env.example Node.js#0x profiling#10x faster python scraper tutorial#12-factor#2026#2FA#@nestjs/throttler#AI#AI Anxiety#AI Backend