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.
POST /api/auth/loginAuthorization headerClient Server
| |
|-- POST /login (email, pwd) -->|
| | (verify credentials)
|<-- { token: "eyJ..." } -------|
| |
|-- GET /api/me |
| Authorization: Bearer eyJ..|
| | (verify signature, read payload)
|<-- { id: "user_123", ... } ---|
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.
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).
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>
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'),
})
A protected route redirects unauthenticated users to the login page. You check whether a valid token exists and redirect if not.
// 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>
}
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>
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
}
The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.