Building Secure Authentication with OAuth 2.0 and JWT in a Full-Stack App

Ever tried to protect a simple “Hello World” API with a password and ended up with a list of angry users and a broken login flow? That’s the moment I realized that security isn’t a feature you bolt on after the fact – it’s the foundation of every app you ship. In 2024, with mobile devices everywhere and APIs exposed to the public internet, OAuth 2.0 paired with JSON Web Tokens (JWT) is the de‑facto recipe for a smooth, secure sign‑in experience. Let’s walk through why it matters, how the pieces fit together, and how you can stitch them into a full‑stack project without pulling your hair out.

Why OAuth 2.0 and JWT Are Worth Your Time

OAuth 2.0 is often described as “delegated authorization.” In plain English: it lets a user grant your app limited access to their data without handing over their password. Think of it as a valet key for a car – it starts the engine but can’t open the trunk. JWT, on the other hand, is a compact, URL‑safe token that carries a set of claims (bits of information) about the user and can be verified without a database lookup. Together they give you:

  • Stateless sessions – no need to store session IDs on the server.
  • Scalable APIs – any microservice can validate a token on its own.
  • Better UX – users can sign in with Google, Apple, or any provider you support.

If you’ve ever built a single‑page app (SPA) that talks to a Node/Express backend, you’ll know how messy session cookies become when you add a mobile client. JWT solves that mess, and OAuth 2.0 gives you a clean way to obtain those tokens.

The Core Flow – From Login Button to Protected Endpoint

Below is the high‑level choreography:

  1. User clicks “Sign in with Google.” Your front‑end redirects to Google’s OAuth 2.0 authorization endpoint.
  2. Google authenticates the user (maybe with 2FA) and asks for consent to share basic profile info.
  3. Google redirects back to your app with an authorization code.
  4. Your backend exchanges that code for an access token (and optionally a refresh token) by calling Google’s token endpoint.
  5. Backend creates a JWT that includes the user’s ID, email, and any roles you need.
  6. JWT is sent to the client (usually in a JSON response). The client stores it in memory or a secure httpOnly cookie.
  7. Future API calls include the JWT in the Authorization: Bearer <token> header. Each service validates the token’s signature and claims.

That’s it. No session tables, no “remember me” cookies that linger forever, and no need to reinvent the wheel for each new client platform.

Setting Up the Backend – A Minimal Express Example

I like to keep the backend as lean as possible while still showing the essential steps. Below is a stripped‑down Express server that uses the passport library for the OAuth dance and jsonwebtoken to sign JWTs.

const express = require('express')
const passport = require('passport')
const { Strategy: GoogleStrategy } = require('passport-google-oauth20')
const jwt = require('jsonwebtoken')
require('dotenv').config()

const app = express()
app.use(express.json())

passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: '/auth/google/callback',
    },
    (accessToken, refreshToken, profile, done) => {
      // In a real app you would upsert the user in your DB here.
      const user = {
        id: profile.id,
        email: profile.emails[0].value,
        name: profile.displayName,
      }
      return done(null, user)
    }
  )
)

app.get(
  '/auth/google',
  passport.authenticate('google', { scope: ['profile', 'email'] })
)

app.get(
  '/auth/google/callback',
  passport.authenticate('google', { session: false, failureRedirect: '/' }),
  (req, res) => {
    const payload = {
      sub: req.user.id,
      email: req.user.email,
      name: req.user.name,
    }
    const token = jwt.sign(payload, process.env.JWT_SECRET, {
      expiresIn: '1h',
    })
    // For SPAs you might send JSON; for server‑rendered pages you could set a cookie.
    res.json({ token })
  }
)

// Middleware to protect routes
function authenticateJWT(req, res, next) {
  const authHeader = req.headers.authorization
  if (!authHeader) return res.sendStatus(401)

  const token = authHeader.split(' ')[1]
  jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
    if (err) return res.sendStatus(403)
    req.user = user
    next()
  })
}

app.get('/api/profile', authenticateJWT, (req, res) => {
  res.json({ message: `Hello ${req.user.name}`, email: req.user.email })
})

app.listen(3000, () => console.log('Server running on http://localhost:3000'))

A few things to note:

  • Never hard‑code secrets. Use environment variables (process.env.JWT_SECRET) and keep them out of source control.
  • Set a reasonable expiration (1h in the example). Short‑lived tokens limit the window for abuse.
  • Refresh tokens are optional for many SPAs. If you need long‑term access, store a refresh token securely on the server and issue a new JWT when the old one expires.

Front‑End Integration – React Example

On the client side, the flow is almost identical to what you’d do with any OAuth provider. Here’s a quick React hook that handles the token once it lands in the URL after Google redirects back.

import { useEffect, useState } from 'react'
import axios from 'axios'

export function useAuth() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    const urlParams = new URLSearchParams(window.location.search)
    const token = urlParams.get('token')
    if (token) {
      // Store token in memory; avoid localStorage for XSS safety.
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
      // Decode token payload (optional, for UI)
      const payload = JSON.parse(atob(token.split('.')[1]))
      setUser({ name: payload.name, email: payload.email })
      // Clean up URL
      window.history.replaceState({}, document.title, '/')
    }
  }, [])

  return { user }
}

A couple of practical tips:

  • Avoid localStorage for JWTs unless you have a solid Content Security Policy. In‑memory storage (or httpOnly cookies) reduces the risk of cross‑site scripting attacks.
  • Refresh silently by calling a /auth/refresh endpoint before the token expires. You can schedule a setTimeout based on the exp claim inside the JWT.
  • Handle token revocation by maintaining a blacklist of compromised JWT IDs (jti claim) if you ever need to invalidate a token early.

Common Pitfalls and How to Dodge Them

PitfallWhy It HappensQuick Fix
Storing JWT in localStorageEasy but vulnerable to XSSUse httpOnly cookies or in‑memory variables
Forgetting to verify token signatureAccepts forged tokensAlways call jwt.verify with your secret/public key
Using the same secret for OAuth and JWTConfuses responsibilitiesKeep OAuth client secret separate from JWT signing key
Not rotating signing keysLong‑term key exposureImplement key rotation and store keys in a vault

I learned the hard way that a missing jwt.verify call turned a demo app into a security nightmare during a client hackathon. The lesson? Never assume a token is safe just because it looks like a JWT.

Going Production‑Ready

When you move beyond a prototype, consider these upgrades:

  • Use RSA/ECDSA keys instead of a symmetric secret. Public keys can be shared across services, while private keys stay locked down.
  • Leverage OpenID Connect (OIDC) on top of OAuth 2.0. OIDC adds a standard id_token that already contains user profile info, saving you a database lookup.
  • Implement rate limiting on the token endpoint to thwart credential stuffing.
  • Enable CORS properly – only allow origins you control, and always set Access-Control-Allow-Credentials when using cookies.

TL;DR

OAuth 2.0 gives you a clean way to let users sign in with trusted providers, and JWT lets your services validate those sign‑ins without a central session store. By wiring the two together in a modest Express backend and a React front‑end, you get a stateless, scalable authentication layer that feels modern and stays secure. Keep your secrets safe, respect token lifetimes, and you’ll spend more time building features and less time patching login bugs.

Reactions