Các phương pháp xác thực & bảo mật ứng dụng-P5: JWT (JSON Web Token)

21 phút đọc

🧠 Tổng quan — JWT là gì?

JWT (JSON Web Token) là một chuẩn mở (RFC 7519) dùng để truyền thông tin an toàn giữa các bên dưới dạng một JSON object được ký số (digitally signed).

Hãy hình dung JWT như một thẻ nhân viên điện tử: thẻ chứa thông tin của bạn (tên, phòng ban, quyền hạn), được công ty ký xác nhận. Bảo vệ ở cửa không cần gọi HR để xác minh — họ chỉ cần kiểm tra chữ ký trên thẻ.


🏗️ Cấu trúc JWT

Một JWT gồm 3 phần, phân tách bằng dấu chấm (.):

xxxxx.yyyyy.zzzzz
  │       │      │
Header  Payload  Signature

1. Header

Chứa loại token và thuật toán ký:

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

2. Payload (Claims)

  • Bổ xung

    JWT Payload được định nghĩa trong RFC 7519 — chia claims thành 3 nhóm rõ ràng:


    1. 🔴 Không có claim nào thực sự "bắt buộc" theo RFC

    RFC 7519 không bắt buộc claim nào cả về mặt kỹ thuật — một JWT payload rỗng {} vẫn hợp lệ về cú pháp. Tuy nhiên trong thực tế, một số claim là bắt buộc về mặt bảo mật.


    2. Phân loại claims

    🔴 Bắt buộc trên thực tế (Strongly Recommended → Treat as Required)

    Claim Giá trị Tại sao bắt buộc
    sub User ID (string) Định danh ai đang dùng token. Nếu không có sub, server không biết token này thuộc về user nào. tymon/jwt-auth tự lấy $user->getJWTIdentifier() để set claim này.
    exp Unix timestamp Giới hạn thời gian sống của token. Không có exp → token tồn tại mãi mãi → nếu bị lộ, attacker dùng được vĩnh viễn. Đây là lỗ hổng bảo mật nghiêm trọng.
    iat Unix timestamp Thời điểm token được tạo. Dùng để tính tuổi token, phát hiện token cũ, và hỗ trợ token rotation. Hầu hết library tự thêm.

    🟡 Nên có (Best Practice)

    Claim Giá trị ví dụ Tại sao nên có
    iss "<https://api.myapp.com>" Ai cấp token này? Nếu nhiều service cùng dùng JWT, iss giúp Resource Server từ chối token từ nguồn không tin cậy. Ví dụ: service B không nhận token do service A cấp.
    aud "mobile-app" hoặc ["api", "mobile"] Token này dùng cho ai? Ngăn confused deputy attack — token cấp cho app mobile không nên dùng được cho admin API.
    jti UUID v4 JWT ID — định danh duy nhất của token. Dùng để implement blacklist khi logout, hoặc ngăn token bị dùng lại (replay attack).
    nbf Unix timestamp Not Before — token chưa hợp lệ trước thời điểm này. Hữu ích khi cấp token trước (pre-issue) cho một sự kiện trong tương lai.

    🟢 Tuỳ chọn — Custom Claims (Private Claims)

    Đây là data bạn tự định nghĩa, được nhúng vào payload để Resource Server không cần query DB:

    Claim Ví dụ Tại sao thêm Lưu ý
    role "admin" Phân quyền không cần DB query Chỉ lưu role tổng, không lưu permission list dài
    email "user@example.com" Hiển thị nhanh ở frontend Không lưu nếu email có thể thay đổi
    plan "premium" Kiểm tra gói dịch vụ Nên để exp ngắn để tránh stale data
    tenant_id "org_123" Multi-tenant apps Cần thiết khi một user thuộc nhiều tổ chức

    3. Những thứ KHÔNG được lưu trong payload

    Tuyệt đối không lưu:

    • password hay bất kỳ secret nào
    • Số CMND, thẻ tín dụng, thông tin nhạy cảm
    • Permission list dài (ví dụ: 50 permissions) → token phình to, gửi lên mỗi request

    Lý do: Payload chỉ được Base64URL encode, không phải mã hóa. Bất kỳ ai intercept được token đều có thể decode và đọc payload bằng base64_decode() hoặc trực tiếp trên jwt.io.


    4. Cụ thể trong Laravel (tymon/jwt-auth)

    JWTAuth::fromUser($user) tự động thêm các claims này:

    // Payload được tạo tự động:
    {
      "sub": "1",           // từ $user->getJWTIdentifier()
      "iat": 1711234567,    // thời điểm tạo
      "exp": 1711238167,    // iat + jwt.ttl (config)
      "nbf": 1711234567,    // = iat (mặc định)
      "jti": "abc123...",   // UUID tự generate
      "iss": "<http://localhost>",   // APP_URL
      "prv": "87e0af1..."   // hash của provider class (internal)
    }
    

    Thêm custom claims bằng cách implement JWTSubject trên User model:

    // app/Models/User.php
    use Tymon\\JWTAuth\\Contracts\\JWTSubject;
    
    class User extends Authenticatable implements JWTSubject
    {
        // Bắt buộc — trả về giá trị cho claim 'sub'
        public function getJWTIdentifier(): mixed
        {
            return $this->getKey(); // thường là $this->id
        }
    
        // Custom claims — thêm data vào payload
        public function getJWTCustomClaims(): array
        {
            return [
                'role'      => $this->role,       // ✅ nên có
                'tenant_id' => $this->tenant_id,  // ✅ nếu multi-tenant
                // 'email'  => $this->email,       // ⚠️ cân nhắc nếu email có thể đổi
                // 'permissions' => $this->permissions, // ❌ quá nặng nếu list dài
            ];
        }
    }
    

    5. Tóm tắt nhanh

    🔴 Bắt buộc thực tế:  sub, exp, iat
    🟡 Nên có:            iss, aud, jti
    🟢 Tuỳ chọn:          role, tenant_id, plan, email
    🚫 Không được lưu:    password, secrets, data nhạy cảm, list quá dài
    

    Rule of thumb: Payload chỉ nên chứa thông tin mà mọi request đều cần, đủ để xác định aicó quyền gì — không hơn không kém.

Chứa thông tin người dùng và metadata. Lưu ý: phần này chỉ được mã hóa Base64, KHÔNG được mã hóa — bất kỳ ai cũng có thể đọc được.

{
  "sub": "user_123",
  "name": "Trung Phan",
  "role": "admin",
  "iat": 1711234567,
  "exp": 1711235467
}
Claim Ý nghĩa
sub Subject — ID người dùng
iat Issued At — thời điểm tạo token
exp Expiration — thời điểm hết hạn
role Custom claim — quyền hạn người dùng

3. Signature

Được tạo bằng cách ký Header + Payload với secret key hoặc private key:

RS256(base64(header) + "." + base64(payload), privateKey)

Chữ ký này đảm bảo không ai có thể giả mạo hoặc sửa đổi nội dung token mà không bị phát hiện.


⚙️ Cơ chế hoạt động — Flow xác thực

sequenceDiagram
    participant Client as 🖥️ Client (Browser/App)
    participant Auth as 🔐 Auth Server
    participant API as 📦 Resource Server (API)

    Client->>Auth: POST /login (username + password)
    Auth->>Auth: Xác minh thông tin đăng nhập
    alt ✅ Đăng nhập thành công
        Auth->>Auth: Tạo JWT (Header + Payload + Ký số)
        Auth-->>Client: 200 OK + Access Token (JWT) + Refresh Token
    else ❌ Thất bại
        Auth-->>Client: 401 Unauthorized
    end

    Note over Client: Lưu Access Token (memory/cookie)

    Client->>API: GET /api/data\nAuthorization: Bearer eyJhbG...
    API->>API: 1. Xác minh chữ ký\n2. Kiểm tra exp (hết hạn chưa?)
    alt ✅ Token hợp lệ
        API-->>Client: 200 OK + Dữ liệu
    else ⏰ Token hết hạn
        API-->>Client: 401 Token Expired
        Client->>Auth: POST /refresh (Refresh Token)
        Auth-->>Client: Access Token mới
    end

Giải thích từng bước:

  1. Đăng nhập: Client gửi username/password → Auth Server xác minh → nếu hợp lệ, tạo JWT và trả về.
  2. Lưu token: Client lưu Access Token (thường trong memory hoặc httpOnly cookie).
  3. Gọi API: Mỗi request, Client đính kèm token vào header Authorization: Bearer <token>.
  4. Xác minh: API Server kiểm tra chữ ký và thời hạn — không cần truy vấn database.
  5. Làm mới: Khi Access Token hết hạn, dùng Refresh Token để lấy token mới mà không cần đăng nhập lại.

✅ Ưu điểm

  • Stateless (không trạng thái): Server không cần lưu session → dễ mở rộng (scale) theo chiều ngang.
  • Tự chứa thông tin: Payload chứa đủ thông tin người dùng → giảm truy vấn DB.
  • Linh hoạt: Hoạt động tốt với microservices — nhiều service có thể xác minh cùng một token.
  • Cross-domain: Dễ dùng trong môi trường CORS, mobile app, SPA.

⚠️ Nhược điểm & Rủi ro cần lưu ý

Vấn đề Giải thích Giải pháp
Không thu hồi được Token hợp lệ cho đến khi hết hạn dù user đã logout Đặt exp ngắn (15–30 phút) + Refresh Token
Payload có thể đọc được Base64 ≠ mã hóa, ai cũng decode được Không lưu thông tin nhạy cảm (mật khẩu, CCCD…)
Kích thước lớn Payload càng nhiều claims, token càng to Chỉ lưu claims cần thiết
Thuật toán yếu alg: none hoặc HS256 có thể bị tấn công Dùng RS256 hoặc ES256 cho production

🔐 Best Practices cho Production

  • Dùng thuật toán RS256 hoặc ES256 (asymmetric) thay vì HS256
  • Access Token hết hạn sau 15–30 phút
  • Refresh Token hết hạn sau 7–30 ngày, lưu trong httpOnly cookie
  • Không lưu thông tin nhạy cảm trong payload
  • Validate đầy đủ: chữ ký + exp + iss (issuer) + aud (audience)
  • Implement token rotation: mỗi lần refresh, cấp Refresh Token mới và vô hiệu hóa cái cũ

🔄 Access Token vs Refresh Token

Access Token Refresh Token
Mục đích Xác thực mỗi API request Lấy Access Token mới
Thời hạn Ngắn (15–30 phút) Dài (7–30 ngày)
Nơi lưu Memory / Authorization header httpOnly cookie
Gửi đến Resource Server (API) Auth Server
Khi bị lộ Thiệt hại giới hạn (hết hạn nhanh) Nguy hiểm hơn — cần revoke ngay

🆚 So sánh với Session-based Authentication

Tiêu chí JWT (Stateless) Session (Stateful)
Lưu trữ phía server ❌ Không cần ✅ Cần (DB/Redis)
Khả năng mở rộng ✅ Tốt (scale ngang dễ) ⚠️ Khó hơn (cần shared session store)
Thu hồi token ❌ Khó (cần blacklist) ✅ Dễ (xóa session)
Phù hợp với Microservices, Mobile, SPA Web truyền thống, cần logout ngay lập tức
Kích thước Lớn hơn (token trong header) Nhỏ (chỉ session ID trong cookie)

📚 Tài liệu tham khảo


🔐 Chi tiết Flow Xác thực JWT trong Laravel


1. Auth Server — Xác minh username + password

Analogy: Giống như bảo vệ công ty kiểm tra thẻ nhân viên lần đầu — họ cần xác minh danh tính thật sự của bạn trước khi cấp thẻ.

Khi client gửi POST /api/login, Laravel xử lý qua Controller theo 4 bước:

Bước 1 — Tìm user trong DB

Laravel dùng Eloquent — không cần viết SQL thủ công:

// app/Http/Controllers/AuthController.php
public function login(Request $request)
{
    $request->validate([
        'email'    => 'required|email',
        'password' => 'required|string',
    ]);

    // Eloquent tự generate: SELECT * FROM users WHERE email = ? LIMIT 1
    $user = User::where('email', $request->email)->first();

    if (! $user) {
        return response()->json(['message' => 'Unauthorized'], 401);
    }

Bước 2 — Xác minh password (bcrypt)

Password không bao giờ lưu dưới dạng plaintext. DB lưu dạng: $2y$12$abc...xyz (bcrypt hash).

Cơ chế bcrypt — 3 bước nội bộ:

  1. Lấy salt được nhúng sẵn trong stored_hash
  2. Hash lại password_nhap + salt với cùng cost factor
  3. So sánh kết quả với stored_hashKhông thể reverse, chỉ có thể so sánh

Laravel bọc toàn bộ logic này trong Hash::check():

// Hash::check() = bcrypt(password_nhap + salt) == stored_hash?
if (! Hash::check($request->password, $user->password)) {
    return response()->json(['message' => 'Unauthorized'], 401);
}

// ❌ KHÔNG BAO GIỜ làm thế này:
// if ($request->password === $user->password) ← so sánh plaintext!

Bước 3 — Kiểm tra trạng thái tài khoản

if (! $user->is_active) {
    return response()->json(['message' => 'Account disabled'], 403);
}

// Built-in Laravel: kiểm tra email_verified_at
if (! $user->hasVerifiedEmail()) {
    return response()->json(['message' => 'Email not verified'], 403);
}

// Quá nhiều lần sai → dùng Laravel Throttle middleware hoặc RateLimiter

Bước 4 — Tạo JWT và trả về

JWTAuth::fromUser($user) sẽ tự động:

  • Tạo Header (alg, typ)
  • Tạo Payload (sub = user ID, iat, exp, custom claims)
  • Ký số bằng JWT_SECRET (HS256) hoặc private key (RS256)
    $token = JWTAuth::fromUser($user);

    return response()->json([
        'access_token' => $token,
        'token_type'   => 'bearer',
        'expires_in'   => config('jwt.ttl') * 60, // giây
    ]);
}

Kết quả trả về client:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 3600
}

2. Resource Server — Xác minh JWT token

Analogy: Bảo vệ ở cửa các tầng sau chỉ cần kiểm tra chữ ký trên thẻ — không cần gọi HR (Auth Server) để xác minh lại.

Toàn bộ logic xác minh được đóng gói trong Middleware — Controller không cần xử lý.

Đăng ký Middleware

// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'auth.jwt' => \Tymon\JWTAuth\Http\Middleware\Authenticate::class,
    ]);
})
// routes/api.php
Route::middleware('auth.jwt')->group(function () {
    Route::get('/profile', [UserController::class, 'profile']);
    Route::get('/orders',  [OrderController::class, 'index']);
});

Bên trong Middleware tự động làm 5 bước:

Bước 1 — Parse & validate cấu trúc token

  • Tách Authorization: Bearer <token> → lấy phần token
  • Tách token thành 3 phần: header.payload.signature
  • Kiểm tra định dạng Base64URL hợp lệ
  • Decode header → kiểm tra alg (từ chối alg: none — lỗ hổng bảo mật nghiêm trọng)

Bước 2 — Xác minh chữ ký (quan trọng nhất)

  • Giải thích chi tiết

    Đây là bước cốt lõi nhất của toàn bộ JWT. Hãy đi từ bản chất lên:


    🔑 Câu hỏi cần trả lời: "Token này có bị ai giả mạo/sửa không?"


    Phần 1 — Signature được tạo ra như thế nào? (Khi login)

    Khi Auth Server tạo token, signature được sinh bằng công thức:

    Signature = HMAC_SHA256(
      base64url(Header) + "." + base64url(Payload),
      JWT_SECRET
    )
    

    Ví dụ cụ thể:

    Header  (encoded): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    Payload (encoded): eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIn0
    
    Input vào hàm ký:
    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIn0"
    
    JWT_SECRET = "super-secret-key"
    
    → Signature = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
    

    Token hoàn chỉnh gửi về client:

    eyJhbGci...  .  eyJzdWIi...  .  SflKxwRJ...
       Header         Payload        Signature
    

    Phần 2 — Xác minh chữ ký làm gì? (Khi nhận request)

    Khi Resource Server nhận token, nó thực hiện đúng 1 thao tác:

    "Tự tính lại Signature từ Header + Payload trong token, rồi so sánh với Signature đính kèm."

    Token nhận được:
      Header   = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
      Payload  = eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIn0
      Signature_nhận = SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
    
    Server tự tính lại:
      Signature_tính = HMAC_SHA256(Header + "." + Payload, JWT_SECRET)
                   = SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
    
    So sánh:
      Signature_nhận == Signature_tính ?
      → true  ✅ Token hợp lệ
      → false ❌ Token bị giả mạo
    

    Phần 3 — Tại sao sửa 1 ký tự trong Payload là bị phát hiện ngay?

    Giả sử attacker intercept token và sửa role từ "user" thành "admin":

    Payload gốc:    { "sub": "1", "role": "user"  }  → hash X
    Payload giả:    { "sub": "1", "role": "admin" }  → hash Y  (hoàn toàn khác)
    

    HMAC là hàm một chiều nhạy với mọi thay đổi — thay đổi 1 bit input → output thay đổi hoàn toàn (Avalanche Effect).

    Attacker không thể tạo ra Signature mới hợp lệ vì không biết JWT_SECRET. Nên khi server tính lại:

    HMAC(payload_giả, JWT_SECRET) = Y  ≠  X (signature gốc)
    → false ❌ → TokenInvalidException
    

    Phần 4 — HS256 vs RS256 — khác nhau chỗ nào?

    HS256 (Symmetric) RS256 (Asymmetric)
    Ký bằng JWT_SECRET private key
    Verify bằng JWT_SECRET (cùng key) public key (khác key)
    Ai có thể verify Ai biết secret đều verify được Ai cũng có thể verify với public key
    Rủi ro Lộ secret = mất hoàn toàn Lộ public key = không sao
    Dùng khi Monolith, đơn giản Microservices, nhiều service verify

    Với HS256 (mặc định của tymon/jwt-auth):

    Ký:    HMAC_SHA256(data, JWT_SECRET)   → Auth Server dùng
    Verify: HMAC_SHA256(data, JWT_SECRET)  → Resource Server dùng cùng key
    

    Với RS256:

    Ký:    RSA_sign(data, private_key)     → chỉ Auth Server có
    Verify: RSA_verify(data, public_key)   → bất kỳ service nào cũng verify được
    

    Phần 5 — Điểm quan trọng cần nắm

    1. Signature KHÔNG mã hóa Payload — nó chỉ đảm bảo tính toàn vẹn (integrity). Payload vẫn đọc được bằng Base64 decode.

    2. Server KHÔNG lưu token — toàn bộ xác minh dựa vào việc tính lại hash và so sánh. Đây là lý do JWT stateless.

    3. Bảo mật phụ thuộc hoàn toàn vào JWT_SECRET — key bị lộ = attacker có thể tự tạo token hợp lệ với bất kỳ payload nào. Phải giữ secret trong .env, không commit lên git.

    4. alg: none là lỗ hổng chết người — một số thư viện cũ nếu không validate alg, attacker có thể gửi token với alg: none và signature rỗng → server chấp nhận. Luôn whitelist algorithm được phép dùng.


    Tóm gọn bằng 1 câu

    Xác minh chữ ký = "Tôi tính lại hash từ Header+Payload bằng secret của tôi — nếu khớp với signature trong token, thì token này do tôi cấp và chưa bị ai sửa."

verify(
  base64(header) + "." + base64(payload),  ← nội dung cần kiểm tra
  signature,                                ← chữ ký từ token
  JWT_SECRET hoặc publicKey                ← key để verify
) → true / false

→ Nếu payload bị sửa dù 1 ký tự → signature không khớp → reject ngay.

Bước 3 — Xác minh các claims

Claim Kiểm tra Fail →
exp now() < exp (hết hạn chưa?) 401 Token Expired
nbf now() >= nbf (chưa đến giờ dùng?) 401 Token Not Yet Valid
iss Đúng issuer đã cấu hình? 401 Invalid Issuer
aud Đúng audience của service này? 401 Invalid Audience

Bước 4 — Kiểm tra quyền (Authorization)

Dùng Laravel Gates hoặc Policies sau khi token đã verified:

// Trong Controller
public function deletePost(Post $post)
{
    // Gate dùng role từ JWT payload (đã được gắn vào Auth user)
    $this->authorize('delete', $post);
    // hoặc
    if (! auth()->user()->hasRole('admin')) {
        abort(403, 'Forbidden');
    }
}

Bước 5 — Gắn user vào request context

Sau khi verify xong, middleware tự gắn user vào Auth guard:

// Trong bất kỳ Controller nào (sau middleware)
public function profile()
{
    $user   = auth()->user();   // lấy full User model
    $userId = auth()->id();     // lấy user ID từ payload 'sub'

    return response()->json($user);
}

Xử lý lỗi trong Middleware (tự viết để hiểu rõ hơn)

// app/Http/Middleware/JwtMiddleware.php
public function handle(Request $request, Closure $next)
{
    try {
        // Bước 1+2+3 đều nằm trong dòng này:
        $user = JWTAuth::parseToken()->authenticate();

    } catch (TokenExpiredException $e) {
        return response()->json(['message' => 'Token expired'], 401);

    } catch (TokenInvalidException $e) {
        // Sai chữ ký, sai cấu trúc, alg: none...
        return response()->json(['message' => 'Token invalid'], 401);

    } catch (JWTException $e) {
        // Không có token trong header
        return response()->json(['message' => 'Token absent'], 401);
    }

    return $next($request);
}

🗺️ Sơ đồ tổng thể trong Laravel

sequenceDiagram
    participant C as 🖥️ Client
    participant AC as 🔐 AuthController
    participant DB as 🗄️ Database
    participant MW as 🛡️ JWT Middleware
    participant RC as 📦 ResourceController

    C->>AC: POST /api/login { email, password }
    AC->>DB: User::where('email')->first()
    DB-->>AC: $user (hoặc null → 401)
    AC->>AC: Hash::check(password, user.password)
    Note over AC: bcrypt verify — không thể reverse
    AC->>AC: Kiểm tra is_active, email_verified
    AC->>AC: JWTAuth::fromUser($user)
    Note over AC: Tạo header+payload, ký bằng JWT_SECRET
    AC-->>C: { access_token: "eyJ..." }

    Note over C: Lưu token (memory / httpOnly cookie)

    C->>MW: GET /api/profile<br/>Authorization: Bearer eyJ...
    MW->>MW: Parse token → verify signature
    MW->>MW: Kiểm tra exp, iss, aud
    MW->>MW: Auth::setUser($user)
    MW->>RC: $next($request) ✅
    RC->>RC: auth()->user() → lấy user
    RC-->>C: 200 OK + { user data }

📊 Tóm tắt so sánh Auth Server vs Resource Server

Auth Server (AuthController) Resource Server (JWT Middleware)
Dùng key nào JWT_SECRET hoặc private key để JWT_SECRET hoặc public key để verify
Cần DB không ✅ Có — tìm user, so sánh hash ❌ Không — stateless hoàn toàn
Xác minh gì Identity: "Bạn là ai?" Authorization: "Bạn có quyền không?"
Laravel class AuthControllerHash::check() JWTAuth::parseToken()->authenticate()
Khi thất bại 401 Unauthorized / 403 Forbidden 401 Token Expired / Token Invalid

Bài viết liên quan

Đang cập nhật...