Back to Blog
Node.js7 min read

Authentication & Authorization: JWT vs OAuth2 Deep Dive

A full-stack developer's guide to implementing secure authentication and authorization with JWT, OAuth2, and session management in production apps.

Jay Salot

Jay Salot

Senior Full Stack AI Engineer

July 27, 2026 · 7 min read

Share
Global network and technology

Last year, I inherited a Node.js API that used JWTs for everything. Sessions, password resets, email verification—all JWTs. The refresh token implementation was broken, tokens were stored in localStorage (yikes), and the whole thing fell apart when we needed to revoke a user's access immediately. That project taught me the hard way that authentication and authorization aren't just about slapping a library on your app and calling it done.

Let me walk you through what I've learned building auth systems across different projects, from simple Express APIs to complex microservices on GCP Cloud Run.

Authentication vs Authorization: Get This Right First

I see these terms used interchangeably all the time, but they're fundamentally different:

Authentication answers "Who are you?" It's proving identity. Login with email/password, OAuth2 with Google, magic links—all authentication mechanisms.

Authorization answers "What can you do?" It's about permissions. Can this user delete posts? Can they access admin routes? Can they view this specific resource?

In practice, you always authenticate first, then authorize. The gotcha here is that many developers stop at authentication. They check if a user is logged in but never properly verify what that user should be allowed to do.

JWT Tokens: The Good Parts

JWTs are stateless, which is their biggest advantage. You encode user data into the token itself, sign it, and you're done. No database lookup on every request.

import jwt from 'jsonwebtoken';

interface TokenPayload {
  userId: string;
  email: string;
  role: string;
}

function generateAccessToken(payload: TokenPayload): string {
  return jwt.sign(
    payload,
    process.env.JWT_SECRET!,
    { expiresIn: '15m' } // Short-lived is critical
  );
}

function verifyToken(token: string): TokenPayload {
  try {
    return jwt.verify(token, process.env.JWT_SECRET!) as TokenPayload;
  } catch (error) {
    throw new Error('Invalid token');
  }
}

This works great for microservices. I've used this pattern across Lambda functions and Cloud Run services where you don't want to hit a centralized session store on every request. Each service can independently verify the JWT.

The Problems Nobody Talks About

Here's what bit me: you can't revoke JWTs. Once issued, they're valid until expiration. If a user's account gets compromised, you can change their password, but their existing JWT still works until it expires.

Solutions I've tried:

  • Keep access tokens short-lived (15 minutes or less)
  • Use refresh tokens stored in your database with revocation capability
  • Maintain a Redis blacklist of revoked tokens (adds back that database lookup you were trying to avoid)
  • Include a "token version" in your user model and increment it on password change

Honestly, that last approach is my preferred method now:

interface User {
  id: string;
  email: string;
  tokenVersion: number; // Increment on logout/password change
}

function generateAccessToken(user: User): string {
  return jwt.sign(
    {
      userId: user.id,
      email: user.email,
      tokenVersion: user.tokenVersion
    },
    process.env.JWT_SECRET!,
    { expiresIn: '15m' }
  );
}

async function verifyAndCheckToken(token: string): Promise<TokenPayload> {
  const payload = jwt.verify(token, process.env.JWT_SECRET!) as TokenPayload;
  
  // Validate token version against DB
  const user = await db.users.findById(payload.userId);
  if (!user || user.tokenVersion !== payload.tokenVersion) {
    throw new Error('Token revoked');
  }
  
  return payload;
}

Yeah, this requires a database lookup, but only one per request and you can cache aggressively.

The Refresh Token Pattern

This is where things get real. Access tokens should be short-lived, but you don't want users logging in every 15 minutes. Enter refresh tokens.

The pattern: issue a short-lived access token (JWT) and a long-lived refresh token (stored in your database). When the access token expires, use the refresh token to get a new access token without re-authenticating.

import { randomBytes } from 'crypto';

interface RefreshToken {
  token: string;
  userId: string;
  expiresAt: Date;
  createdAt: Date;
}

async function generateRefreshToken(userId: string): Promise<string> {
  const token = randomBytes(32).toString('hex');
  
  await db.refreshTokens.create({
    token,
    userId,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
    createdAt: new Date()
  });
  
  return token;
}

async function refreshAccessToken(refreshToken: string): Promise<string> {
  const stored = await db.refreshTokens.findOne({ token: refreshToken });
  
  if (!stored || stored.expiresAt < new Date()) {
    throw new Error('Invalid or expired refresh token');
  }
  
  const user = await db.users.findById(stored.userId);
  return generateAccessToken(user);
}

Store refresh tokens in httpOnly cookies, never localStorage. I've seen too many XSS vulnerabilities turn into account takeovers because tokens were in localStorage.

OAuth2: When You Actually Need It

OAuth2 isn't just "login with Google." It's an authorization framework that happens to be used for authentication through OpenID Connect.

I use OAuth2 in two scenarios:

  1. Social login: Let Google/GitHub/Microsoft handle authentication
  2. API authorization: When your app needs to access user data from another service (like reading their Google Calendar)

For social login with Next.js and NextAuth.js (which I use constantly):

// pages/api/auth/[...nextauth].ts
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';

export default NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    async jwt({ token, account, user }) {
      // Persist the OAuth access_token to the token right after signin
      if (account) {
        token.accessToken = account.access_token;
        token.userId = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken;
      session.userId = token.userId;
      return session;
    },
  },
  session: {
    strategy: 'jwt', // Use JWT instead of database sessions
  },
});

NextAuth handles the entire OAuth2 flow for you. The callbacks let you customize what goes into the JWT and session.

OAuth2 Implementation Gotchas

The redirect URI must match exactly what you configured in Google Cloud Console. No trailing slashes, https in production, correct port in development. This trips up everyone at least once.

Also, OAuth2 access tokens from providers (Google, GitHub) are different from your app's access tokens. The provider's token lets you call their API. You still need to issue your own JWT for your app's authentication.

Session Management: The Traditional Approach

Sometimes JWTs are overkill. For traditional server-rendered apps (or Next.js with server components), sessions work great.

import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';

const redisClient = createClient({
  url: process.env.REDIS_URL
});

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days
  }
}));

Sessions give you instant revocation—just delete the session from Redis. The downside is they require a data store lookup on every request and don't work well across microservices without a centralized session store.

What I Do in Production Now

After building auth systems for everything from small startups to enterprise apps, here's my current approach:

For monolithic Next.js apps: NextAuth with JWT sessions, backed by database refresh tokens for revocation capability.

For microservices: JWTs with short expiration, refresh tokens in PostgreSQL, token version checking, and Redis caching of user lookups.

For internal tools: OAuth2 with Google Workspace, leveraging our existing SSO.

Approach Best For Revocation Complexity
Pure JWT Microservices Difficult Low
JWT + Refresh Tokens Most SPAs Good Medium
Sessions Monoliths Excellent Low
OAuth2 Social login, 3rd party API access Provider-dependent High

Security Checklist I Always Follow

  • Never store tokens in localStorage if you can avoid it (XSS risk)
  • Use httpOnly cookies for refresh tokens
  • Implement CSRF protection when using cookies
  • Rotate secrets regularly (I use AWS Secrets Manager with automatic rotation)
  • Log authentication events (failed logins, password changes) to CloudWatch or GCP Logging
  • Rate limit login endpoints (I use Redis for this)
  • Implement proper password hashing with bcrypt (cost factor of 12 minimum)

Final Thoughts

Authentication and authorization aren't solved problems. Every architecture has trade-offs. JWTs are great until you need revocation. Sessions are simple until you scale horizontally. OAuth2 is powerful until you're debugging redirect loops at 2 AM.

My advice: start simple. Use NextAuth or Passport.js if you're building with Node. Don't roll your own crypto. Test your revocation strategy—actually try to break in with an old token after a password change. And always, always separate authentication (who you are) from authorization (what you can do).

The auth system I built last month uses NextAuth with Google OAuth2, JWT sessions with 15-minute expiration, refresh tokens in PostgreSQL, and role-based authorization middleware. It's deployed on Cloud Run with Redis for rate limiting. It's not perfect, but it's secure, maintainable, and it actually works when you need to revoke access.

#authentication#authorization#JWT#OAuth2#security
Share

Related Articles