Skip to content
Chapter 9

Authentication & Security

Proving who you are and keeping things safe

You log into a website. You navigate to 10 different pages. The site knows who you are on every single one. But here's the thing -- HTTP has no memory. Every request is a stranger. So how does the server "remember" you? The answer is one of the cleverest tricks in web development. And getting it wrong isn't just a bug -- it's a breach. Passwords leaked. Accounts hijacked. Data stolen. Authentication is where convenience meets danger, and by the end of this chapter, you'll understand exactly how to handle both.

Authentication vs Authorization: Two Different Questions

Picture yourself arriving at a music festival. At the gate, a person checks your ticket and your ID. Are you really the person whose name is on this ticket? That's authentication -- proving who you are.

Once you're through the gate, they snap a colored wristband onto your wrist. Green means general admission. Gold means VIP with backstage access. The wristband doesn't re-check your identity. It answers a different question: what are you allowed to do? That's authorization.

Two completely different questions. "Who are you?" vs. "What can you do?" And in web development, they have two different names. Authentication (AuthN) verifies identity. Authorization (AuthZ) checks permissions.

Here's why the distinction matters in practice. When a user logs in with their email and password, your server is doing authentication -- confirming that this person really is who they claim to be. But when that same user tries to delete someone else's account, your server is doing authorization -- checking whether this authenticated user has the right permissions for that action. A regular user has a green wristband: they can view and edit their own profile. An admin has a gold wristband: they can access the admin panel and manage other users.

Before reading on, think about this: if you build an app where every logged-in user can do everything, which one did you skip -- authentication or authorization?

You skipped authorization. You checked their ticket at the gate but gave everyone the same wristband. In a real application, you need both. Remember the middleware pipeline from Chapter 8? That's often where authorization lives -- middleware that checks your "wristband" (your role or permissions) before letting a request through to the route handler.

Authentication Flow (JWT)

How login works and how subsequent requests are authenticated

Phase 1: Login
User
email + password
Server
Verify credentials
Hash password + compare
Generate JWT token
User
JWT token
Server
localStorage.setItem("token", jwt)
Phase 2: Authenticated Requests
User
Authorization: Bearer eyJhbG...
Server
Verify Token
Allow
or
Deny (401)

JWT Structure

header.payload.signature

Sessions vs Tokens: Two Schools of Thought

Here's the core problem. You just proved who you are by logging in. But HTTP is stateless -- the next request you send is a complete stranger to the server. So how do you stay logged in across dozens of page loads?

Two schools of thought. The server remembers you (sessions) vs. you carry your own proof (tokens). Let's put them head to head.

SESSION-BASED AUTH: Think of it like a coat check at the festival. You hand over your coat (your credentials), and you get back a small numbered ticket (a session ID). The coat check keeps your coat and a record of who owns ticket #247. Every time you come back and show your ticket, they look up your coat. In web terms: the server stores your session data (user ID, role, login time) and gives your browser a session ID stored in a cookie. On every request, the browser sends that cookie automatically. The server looks up the session ID, finds your data, and knows who you are. The win? The server can revoke access instantly -- just destroy the session record. The cost? The server has to store and look up every active session, which gets heavy at scale.

TOKEN-BASED AUTH (JWT): Now imagine the festival gives you a wristband with your name, ticket type, and access level printed directly on it -- plus a special holographic seal that proves it's genuine and hasn't been tampered with. You carry all your own proof. Nobody needs to look anything up. In web terms: the server creates a JSON Web Token containing your user data, signs it cryptographically, and hands it to the client. On every request, the client sends the token. The server just verifies the signature -- no database lookup needed. The win? Stateless and fast. The cost? You can't easily revoke a token before it expires. If someone steals it, they have access until it runs out.

Here's a question: what if you want the best of both worlds -- stateless speed for routine requests, but the ability to revoke access when needed?

That's exactly what most real apps do. They use short-lived JWTs (15 minutes) as access tokens and longer-lived refresh tokens stored on the server. When the access token expires, the client uses the refresh token to get a new one. If you need to revoke access, you invalidate the refresh token. Look at the code below -- notice how the access token expires in 15 minutes but the refresh token lasts 7 days.

auth-tokens.tstypescript
import jwt from "jsonwebtoken"

const JWT_SECRET = process.env.JWT_SECRET!
const ACCESS_TOKEN_EXPIRY = "15m"  // Short-lived
const REFRESH_TOKEN_EXPIRY = "7d"  // Longer-lived

// Creating tokens at login
function generateTokens(user: { id: number; email: string; role: string }) {
  const accessToken = jwt.sign(
    { id: user.id, email: user.email, role: user.role },
    JWT_SECRET,
    { expiresIn: ACCESS_TOKEN_EXPIRY }
  )

  const refreshToken = jwt.sign(
    { id: user.id },
    JWT_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  )

  return { accessToken, refreshToken }
}

// Login endpoint
app.post("/api/login", async (req, res) => {
  const { email, password } = req.body

  // Find the user in the database
  const user = await db.user.findUnique({ where: { email } })
  if (!user) {
    return res.status(401).json({ error: "Invalid credentials" })
  }

  // Verify password (bcrypt handles the hashing comparison)
  const passwordValid = await bcrypt.compare(password, user.passwordHash)
  if (!passwordValid) {
    return res.status(401).json({ error: "Invalid credentials" })
  }

  // Generate tokens
  const tokens = generateTokens(user)

  // Set refresh token as HTTP-only cookie (more secure)
  res.cookie("refreshToken", tokens.refreshToken, {
    httpOnly: true,    // JavaScript can't access this cookie
    secure: true,      // Only sent over HTTPS
    sameSite: "strict", // Prevents CSRF attacks
    maxAge: 7 * 24 * 60 * 60 * 1000,  // 7 days
  })

  // Send access token in response body
  res.json({ accessToken: tokens.accessToken, user: { id: user.id, name: user.name } })
})

OAuth: 'Sign In with Google'

You click "Sign in with Google." What happens next involves three parties, a secret handshake, and zero passwords shared with the app you're signing into.

Why should you care? Because OAuth solves a problem that used to be terrifying. Before OAuth, if you wanted to use a third-party app with your Google data, you'd have to give that app your Google password. Think about that. You'd hand your actual credentials to some random website and just... trust them. OAuth was invented so you'd never have to do that again.

Here's the dance, step by step. Three parties are involved: you (the user), the app you're trying to log into (the client), and Google (the provider).

Step 1: You click "Sign in with Google" on the app. The app redirects your browser to Google's login page -- not the app's page, Google's actual page. Step 2: You log in directly with Google. The app never sees your Google password. Google asks, "This app wants to know your name and email -- allow?" You click yes. Step 3: Google redirects you back to the app with a one-time authorization code. Think of it like a claim ticket. Step 4: The app's SERVER (not the browser -- this is critical) takes that code and sends it to Google along with a secret key that proves the app is who it says it is. Step 5: Google verifies everything and sends back the user's info (name, email, profile picture). Step 6: The app creates its own session or JWT for you. Done.

Before reading on, think about this: why does step 4 have to happen server-to-server? Why can't the browser handle that exchange?

Because step 4 includes the app's client_secret -- a private key that proves this request is really coming from the app and not from someone who intercepted the authorization code. If that secret were exposed in browser code, anyone could impersonate the app. This is the same kind of server-side-only logic we talked about with API routes in Chapter 6.

Libraries like Auth.js (formerly NextAuth.js) handle this entire dance for you. But now you know what's happening behind the curtain -- and you'll know what's wrong when it breaks.

oauth-flow.tstypescript
// Simplified OAuth 2.0 flow illustration

// Step 1: Redirect user to the OAuth provider
app.get("/auth/google", (req, res) => {
  const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth")
  authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!)
  authUrl.searchParams.set("redirect_uri", "http://localhost:3000/auth/callback")
  authUrl.searchParams.set("response_type", "code")
  authUrl.searchParams.set("scope", "openid email profile")

  res.redirect(authUrl.toString())
})

// Step 2: Google redirects back with an authorization code
app.get("/auth/callback", async (req, res) => {
  const { code } = req.query

  // Step 3: Exchange the code for tokens (server-to-server)
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      code,
      client_id: process.env.GOOGLE_CLIENT_ID,
      client_secret: process.env.GOOGLE_CLIENT_SECRET,
      redirect_uri: "http://localhost:3000/auth/callback",
      grant_type: "authorization_code",
    }),
  })

  const { access_token } = await tokenResponse.json()

  // Step 4: Use the access token to get user info
  const userResponse = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
    headers: { Authorization: `Bearer ${access_token}` },
  })

  const googleUser = await userResponse.json()
  // { id: "...", email: "alice@gmail.com", name: "Alice", picture: "..." }

  // Step 5: Create or find user in YOUR database, create YOUR session
  const user = await findOrCreateUser(googleUser)
  const session = await createSession(user.id)

  res.cookie("session", session.id, { httpOnly: true, secure: true })
  res.redirect("/dashboard")
})

Security Essentials: The Threats Are Real

Here are the ways someone can destroy your app. This isn't theoretical -- these attacks happen to real companies, every week, costing millions. Once you see how they work, you'll understand exactly how to stop them.

SQL INJECTION -- the skeleton key. Imagine your app builds a database query like this: "SELECT * FROM users WHERE id = " + whatever the user typed. Seems fine. But what if the user types "1; DROP TABLE users"? Your database just executed two commands: looked up user 1, then deleted your entire users table. Gone. Every user, every record.

The fix is simple and absolute: never concatenate user input into queries. Use parameterized queries, or use an ORM like Prisma (Chapter 7) which parameterizes automatically. This is why we used Prisma's findUnique() instead of writing raw SQL -- the ORM protects you by default.

CROSS-SITE SCRIPTING (XSS) -- the puppet master. An attacker posts a comment on your site containing malicious JavaScript: <script>steal(document.cookie)</script>. If your app renders that comment without escaping it, the script runs in every user's browser who views that page.

It can steal their cookies, their tokens, their sessions. React protects you here -- it escapes content by default. But if you use dangerouslySetInnerHTML (the name is a warning!), you're on your own.

CROSS-SITE REQUEST FORGERY (CSRF) -- the impersonator. You're logged into your bank. You visit a shady website. That website has a hidden form that automatically submits a money transfer request to your bank's API.

Since your browser automatically sends your bank's cookies with every request to your bank, the request goes through as if you made it. The fix? SameSite cookies (which we set in the token code above) and CSRF tokens.

Now for the habits that protect you from everything else. Never store passwords in plain text -- always hash them with bcrypt (you'll see this in the code below). Remember Chapter 7, where we talked about what gets stored in the database? Passwords are stored as hashes, never as the original text. Never commit secrets to version control -- use environment variables. Always validate user input on the server, because client-side validation can be bypassed by anyone with a browser dev tools open. Use HTTPS in production. Keep your dependencies updated, because known vulnerabilities in outdated packages are low-hanging fruit for attackers.

Here's a question: why do we say "Invalid credentials" instead of "User not found" or "Wrong password" in the login code above?

Because telling an attacker which part was wrong gives them information. "User not found" confirms that email doesn't have an account. "Wrong password" confirms the email DOES have an account. "Invalid credentials" reveals nothing.

security-essentials.tstypescript
import bcrypt from "bcrypt"

// --- PASSWORD HASHING ---
// NEVER store plain text passwords!

// When user registers: hash the password
async function registerUser(email: string, password: string) {
  const saltRounds = 12
  const passwordHash = await bcrypt.hash(password, saltRounds)

  // Store the HASH, not the password
  await db.user.create({
    data: { email, passwordHash },
  })
}

// When user logs in: compare against the hash
async function verifyPassword(password: string, hash: string) {
  return bcrypt.compare(password, hash) // returns true/false
}

// --- SQL INJECTION PREVENTION ---

// VULNERABLE: string concatenation
// const query = "SELECT * FROM users WHERE id = " + userId
// If userId is "1; DROP TABLE users", your database is gone!

// SAFE: parameterized query (using Prisma)
const user = await prisma.user.findUnique({ where: { id: userId } })

// SAFE: parameterized query (raw SQL)
const user2 = await db.query("SELECT * FROM users WHERE id = $1", [userId])

// --- ENVIRONMENT VARIABLES ---
// .env (NEVER commit this file to git!)
// DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
// JWT_SECRET=your-super-secret-key-here
// GOOGLE_CLIENT_SECRET=your-google-secret

// .gitignore should include:
// .env
// .env.local
// .env.*.local

// Access in code:
const dbUrl = process.env.DATABASE_URL
const jwtSecret = process.env.JWT_SECRET
Key Takeaways
  • Authentication is the gate check (who are you?). Authorization is the wristband (what can you access?). Never confuse them -- your app needs both.
  • Sessions store your identity on the server (easy to revoke, heavier to scale). Tokens carry your identity with you (stateless and fast, harder to revoke). Most production apps combine both with short-lived JWTs and server-stored refresh tokens.
  • OAuth lets users sign in with Google, GitHub, or other providers through a multi-step handshake that never exposes their password to your app. Libraries like Auth.js handle the complexity for you.
  • SQL injection, XSS, and CSRF are not theoretical -- they are active, common attacks. Use parameterized queries, let React escape output, and set SameSite cookies to defend against them.
  • The security basics are non-negotiable: hash passwords with bcrypt, never commit secrets to git, validate input on the server, use HTTPS, and keep dependencies updated.
Quiz
Question 1 of 3

At a music festival, checking someone's ticket at the gate is like _____, and their colored wristband determining which stages they can access is like _____.