Week 12

TanStack Query

Optimistic updates

Authentication

Data persistance

Security Fundamentals

Practice

Assignment

Frontend Track

JWT

A JSON Web Token (JWT) is a compact, self-contained string that proves the holder is who they claim to be. It looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJleHAiOjE3MDAwMDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

It has three parts separated by dots: <header>.<payload>.<signature>.

Header - base64-encoded JSON describing the token type and signing algorithm:

{ "alg": "HS256", "typ": "JWT" }

Payload - base64-encoded JSON containing claims about the user:

{
  "sub": "user_123",
  "email": "[email protected]",
  "role": "admin",
  "exp": 1700000000
}

Common claims: sub (subject - user ID), exp (expiry timestamp), iat (issued at), email, role. The payload is not encrypted - anyone can decode it. Don't put passwords or secrets here.

Signature - a cryptographic hash of the header and payload, signed with a secret key the server holds. The server uses this to verify the token wasn't tampered with.

Login flow

  1. User submits credentials to POST /api/auth/login
  2. Server verifies the credentials, generates a JWT signed with its secret key
  3. Server returns the token to the client
  4. Client stores the token (more on where below)
  5. Client includes the token in subsequent requests via the Authorization header
  6. Server validates the signature and reads the payload - no database lookup needed
Client                          Server
  |                               |
  |-- POST /login (email, pwd) -->|
  |                               | (verify credentials)
  |<-- { token: "eyJ..." } -------|
  |                               |
  |-- GET /api/me                 |
  |   Authorization: Bearer eyJ..|
  |                               | (verify signature, read payload)
  |<-- { id: "user_123", ... } ---|

Storing Tokens

localStorage - common but risky

localStorage.setItem('token', jwt)
const token = localStorage.getItem('token')

It's easy to use and persists across browser sessions. The problem is that any JavaScript running on the page can read localStorage - including injected scripts from an XSS attack. If an attacker gets JavaScript to run on your page (through a compromised dependency, a vulnerable CDN, or an innerHTML injection), they can steal the token and impersonate the user.

httpOnly cookies - harder to steal

When the server sets an httpOnly cookie, JavaScript cannot read it at all. The browser attaches it automatically to every matching request. An XSS attack can still run scripts on the page, but it cannot read the token out of the cookie.

Set-Cookie: token=eyJ...; HttpOnly; Secure; SameSite=Strict; Path=/

The trade-off: httpOnly cookies require server cooperation. Your backend has to set the cookie and the browser manages it. You also need to handle CSRF protection (covered later).

The practical reality

In this course, we use localStorage for simplicity. In production, prefer httpOnly cookies for anything that matters.

localStorage httpOnly cookie
Readable by JS Yes No
Sent automatically No (you add it to headers) Yes (browser does it)
XSS risk High Low
CSRF risk Low Needs mitigation
Easy to implement Yes Requires backend cooperation

<aside> ⚠️

Never store passwords, payment details, or other sensitive data in localStorage. A JWT stored there is already a significant risk - anything more sensitive should not be on the client at all.

</aside>

Attaching Tokens to Requests

Once you have a token, send it in the Authorization header with the Bearer scheme:

async function fetchWithAuth(url: string) {
  const token = localStorage.getItem('token')

  const response = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  })

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`)
  }

  return response.json()
}

With TanStack Query, pass this as your queryFn:

const { data } = useQuery({
  queryKey: ['profile'],
  queryFn: () => fetchWithAuth('/api/me'),
})

Protected Routes

A protected route redirects unauthenticated users to the login page. You check whether a valid token exists and redirect if not.

A simple token check hook

// hooks/useRequireAuth.ts
'use client'

import { useEffect } from 'react'
import { useRouter } from 'next/navigation'

export function useRequireAuth() {
  const router = useRouter()

  useEffect(() => {
    const token = localStorage.getItem('token')
    if (!token) {
      router.replace('/login')
    }
  }, [router])
}
// app/dashboard/page.tsx
'use client'

import { useRequireAuth } from '@/hooks/useRequireAuth'

export default function DashboardPage() {
  useRequireAuth() // redirects if no token

  return <h1>Welcome back</h1>
}

Checking expiry

A token might exist in localStorage but be expired. The payload contains an exp claim (Unix timestamp in seconds). You can decode and check it client-side:

function isTokenExpired(token: string): boolean {
  try {
    const payload = JSON.parse(atob(token.split('.')[1]))
    return payload.exp < Date.now() / 1000
  } catch {
    return true // malformed token - treat as expired
  }
}

export function useRequireAuth() {
  const router = useRouter()

  useEffect(() => {
    const token = localStorage.getItem('token')
    if (!token || isTokenExpired(token)) {
      localStorage.removeItem('token')
      router.replace('/login')
    }
  }, [router])
}

<aside> ⚠️

Client-side expiry checks are a UX convenience - they prevent the user from staring at a broken page. They are not a security measure. The server must always validate the token on every request, regardless of what the client believes.

</aside>

Auth context

For larger apps, keep auth state in a React context so any component can check the login status without re-reading localStorage:

// context/AuthContext.tsx
'use client'

import { createContext, useContext, useState, useEffect } from 'react'

type AuthContextType = {
  token: string | null
  login: (token: string) => void
  logout: () => void
}

const AuthContext = createContext<AuthContextType | null>(null)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [token, setToken] = useState<string | null>(null)

  useEffect(() => {
    setToken(localStorage.getItem('token'))
  }, [])

  function login(newToken: string) {
    localStorage.setItem('token', newToken)
    setToken(newToken)
  }

  function logout() {
    localStorage.removeItem('token')
    setToken(null)
  }

  return (
    <AuthContext.Provider value={{ token, login, logout }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const ctx = useContext(AuthContext)
  if (!ctx) throw new Error('useAuth must be used within AuthProvider')
  return ctx
}

Additional Resources

Reading


The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

CC BY-NC-SA 4.0 Icons

Built with ❤️ by the HackYourFuture community · Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.