JWT Sessions on Top of Google OAuth: Why Both

Passport's Google OAuth20 strategy proves identity once at login; a signed JWT with a 7-day expiry does everything after that. Neither one replaces the other.

JWT Sessions on Top of Google OAuth: Why Both

backend/package.json lists passport, passport-google-oauth20, express-session, and jsonwebtoken as dependencies side by side, which looks redundant at first — why do you need both a session library and a token library for what's ostensibly one login flow? The answer is that they handle two different questions that only look like one: "who are you," answered once by Google, and "are you still who you said you were," answered on every single request after that, without asking Google again.

Two auth mechanisms, two jobs
Two auth mechanisms, two jobs

OAuth answers "who are you" once. JWT answers "are you still you" on every request after.

Google OAuth handles exactly one moment

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  callbackURL: process.env.GOOGLE_CALLBACK_URL,
}, async (accessToken, refreshToken, profile, done) => {
  let user = await User.findByGoogleId(profile.id);
  if (!user) {
    user = await User.create({
      username: profile.displayName,
      email: profile.emails[0].value,
      googleId: profile.id,
    });
  }
  return done(null, user);
}));

This callback runs exactly once per login — Google redirects back with a signed assertion that this person controls this Google account, the app maps that to a local user row, and from that point forward Passport's job is finished. express-session exists here mainly as Passport's own plumbing requirement for the OAuth redirect dance itself (it needs somewhere to stash state between the redirect to Google and the callback), not as the mechanism that keeps a user logged in day to day.

JWT handles every request after that moment

router.get('/auth/google/callback',
  passport.authenticate('google', { session: false }),
  (req, res) => {
    const token = jwt.sign(
      { userId: req.user.id, email: req.user.email },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_EXPIRE || '7d' }
    );
    res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${token}`);
  }
);

{ session: false } is the detail that makes the split explicit: once Passport has done its one job (verifying the Google identity), the app issues its own signed JWT and stops relying on server-side session state entirely. The client stores that token and sends it as a bearer header on every subsequent request; the backend verifies the signature and expiry, with no database lookup and no session store required:

function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (err) {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}

Why not just keep using sessions for everything

A server-side session (cookie holding a session ID, session data stored in Postgres or Redis) would work too, and is arguably simpler to reason about for revocation — delete the row, the session is dead. The tradeoff Fishing Tracker Pro made instead: a stateless JWT means the auth check on every request is pure computation (verify a signature) instead of a database round-trip, which matters more on a small VPS running Postgres, the backend, and the frontend on the same host than it would on infrastructure with a dedicated session cache. The cost is the standard JWT tradeoff — you can't revoke a single token early without a denylist, so JWT_EXPIRE=7d is the real ceiling on how long a compromised token stays valid, not "until the user logs out."

Local email/password login uses the same JWT issuance

Registered-by-email users never touch the Passport Google strategy at all — authController.login checks the bcrypt hash and calls the identical jwt.sign() call shown above. Both auth paths converge on the same token format immediately, so every downstream route only ever has to understand one authentication mechanism (a bearer JWT), regardless of how the user originally proved who they were.

Series: Fishing Tracker Pro. Next, the last post in this series: a year of Dependabot bumps reviewed by exactly one person.