กลับไปที่บทความ
Authentication Security Backend Node.js

JWT Authentication กลยุทธ์การยืนยันตัวตนสำหรับแอปเว็บสมัยใหม่

พลากร วรมงคล
10 เมษายน 2568 14 นาที

“คำแนะนำการใช้งาน JWT authentication อย่างครบถ้วน — ครอบคลุมโครงสร้างของ token, อัลกอริทึมการเซ็นต์, แพตเทิร์นของ access/refresh token, การใช้งาน middleware, กลยุทธ์การเพิกถอน token และแนวปฏิบัติด้านความปลอดภัย”

Deep Dive: JWT Authentication

What is JWT?

JSON Web Token (JWT, ออกเสียงว่า “jot”) คือมาตรฐานแบบเปิด (RFC 7519) สำหรับการส่งข้อมูลอย่างปลอดภัยระหว่างฝ่ายต่างๆ ในรูปแบบ token ที่กระชับและปลอดภัยต่อ URL ต่างจากการยืนยันตัวตนแบบ session-based ที่เซิร์ฟเวอร์ต้องเก็บสถานะของผู้ใช้ JWT นั้น stateless — token นั้นเองประกอบด้วยข้อมูลทั้งหมดที่จำเป็นในการยืนยันตัวตนของผู้ใช้ โดยลงนามด้วยวิธีการเข้ารหัสเพื่อป้องกันการแก้ไขไม่ได้รับอนุญาต

JWT คือสตริงที่ประกอบด้วยสามส่วนที่เข้ารหัส Base64URL คั่นด้วยจุด: header.payload.signature header ระบุอัลกอริทึมการลงนาม payload นำข้อมูล (claims) ของผู้ใช้ และ signature รับประกันความสมบูรณ์ เซิร์ฟเวอร์ใดๆ ที่มีคีย์ลงนามสามารถตรวจสอบ token ได้อย่างอิสระ — ไม่ต้องค้นหาฐานข้อมูล ไม่ต้องใช้ session store ร่วม

JWT เกิดขึ้นจากระบบนิเวศ OAuth 2.0 และ OpenID Connect และได้กลายเป็นมาตรฐานแบบ de facto สำหรับการยืนยันตัวตน API, การสื่อสาร microservice และแบ็กเอนด์ของแอปพลิเคชันมือถือ

Core Principles

  • Stateless: Token เป็นแบบ self-contained เซิร์ฟเวอร์ตรวจสอบโดยใช้การเข้ารหัส ไม่ใช่การค้นหาฐานข้อมูล
  • Compact: เล็กพอที่จะส่งใน HTTP headers, URL parameters หรือ POST bodies
  • Signed, not encrypted: ใครก็ตามสามารถอ่าน payload ได้ (เข้ารหัส Base64 ไม่ใช่เข้ารหัสแบบปิด) signature เพียงรับประกันว่าไม่ได้ถูกแก้ไข ไม่ว่าห้ามใส่ secrets ลงใน payload
  • Expiry-based lifecycle: Token นั้นใช้ได้จนกว่า exp claim จะหมดอายุ token ไม่สามารถเพิกถอนได้หากไม่มีโครงสร้างพิเศษเพิ่มเติม (blacklists)

Authentication Flow

sequenceDiagram
    participant B as Browser / Client
    participant S as Server
    participant DB as Database

    B->>S: POST /login {email, password}
    S->>DB: Validate credentials
    DB-->>S: User {id, email, role}
    S->>S: Sign JWT (access token, 15min)
    S->>S: Sign JWT (refresh token, 7d)
    S->>DB: Store refresh token hash
    S-->>B: {accessToken, refreshToken, expiresIn: 900}
    Note over B: Store tokens (memory / httpOnly cookie)

    B->>S: GET /api/data (Authorization: Bearer <accessToken>)
    S->>S: Verify JWT signature + expiry
    S-->>B: 200 OK — Protected data

    Note over B: Access token expired after 15min
    B->>S: POST /refresh {refreshToken}
    S->>DB: Verify refresh token hash
    DB-->>S: Valid
    S->>S: Sign new access token
    S-->>B: {accessToken, expiresIn: 900}

ทีนี้เรามาแยกแต่ละส่วนในรายละเอียด

Part 1: Understanding JWT Structure

ส่วนแรกคือ header — วัตถุ JSON ที่อธิบายประเภทของ token และอัลกอริทึมการเซ็นต์:

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

อัลกอริทึมทั่วไป:

  • HS256: HMAC กับ SHA-256 (สมมาตร—ใช้ลับเดียวกันสำหรับเซ็นต์และตรวจสอบ)
  • RS256: RSA กับ SHA-256 (อสมมาตร—ใช้คีย์ส่วนตัวเพื่อเซ็นต์, คีย์สาธารณะเพื่อตรวจสอบ)
  • ES256: ECDSA กับ SHA-256 (อสมมาตร—คีย์ที่สั้นกว่า, เร็วกว่า RSA)

Payload

ส่วนที่สองประกอบด้วย claims — ข้อมูลจริง:

{
  "sub": "user:12345",
  "email": "alice@example.com",
  "role": "admin",
  "iat": 1516239022,
  "exp": 1516242622
}

Claims ที่สงวนไว้:

  • iss (issuer): ใครสร้าง token
  • sub (subject): ใครที่ token นี้อ้างอิง
  • aud (audience): ใครที่ควรยอมรับ token
  • exp (expiration time): Unix timestamp — token ไม่ถูกต้องหลังจากเวลานี้
  • iat (issued at): Unix timestamp — เมื่อสร้าง token
  • nbf (not before): Token ไม่ถูกต้องก่อนเวลานี้
  • jti (JWT ID): ตัวระบุเฉพาะสำหรับ token นี้

คุณสามารถเพิ่ม custom claims ได้ แต่หลีกเลี่ยงการเก็บข้อมูลส่วนบุคคลที่ไวต่อข้อมูล เช่น รหัสผ่าน หมายเลขประกันสังคม หรือข้อมูลทางการเงิน

Signature

ส่วนที่สามคือลายเซ็นดิจิทัล ซึ่งพิสูจน์ว่า token ยังไม่ถูกแก้ไข:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

เซิร์ฟเวอร์ใช้ลับตัวเดียวกัน (หรือคีย์สาธารณะ) เพื่อตรวจสอบลายเซ็นนี้ ถ้าใครแก้ไข payload โดยไม่มี secret ลายเซ็นจะไม่ตรงกัน

ส่วนที่ 2: การศึกษาเชิงลึกเกี่ยวกับอัลกอริทึมการเซ็นต์

การเลือกอัลกอริทึมที่ถูกต้องมีความสำคัญสำหรับความปลอดภัยและประสิทธิภาพ

HS256 (HMAC-SHA256) — สมมาตร

ทั้งไคลเอนต์และเซิร์ฟเวอร์ใช้ลับเดียวกัน

ข้อดี:

  • เข้าใจและใช้งานได้ง่าย
  • เร็ว
  • ดีสำหรับแอปพลิเคชันแบบ monolithic

ข้อเสีย:

  • คุณต้องแบ่งปันลับให้บริการทุกแห่งที่ตรวจสอบ token
  • การรั่วไหลของลับนั้นร้ายแรง — ผู้โจมตีสามารถปลอม token ได้ใดก็ได้
  • ไม่เหมาะสำหรับระบบแบบกระจาย

กรณีการใช้งาน: Microservices ที่มี auth server เดียวและการสื่อสารแบ็กเอนด์แบบเข้ม

RS256 (RSA-SHA256) — อสมมาตร

เซิร์ฟเวอร์เซ็นต์ด้วยคีย์ส่วนตัว บริการตรวจสอบด้วยคีย์สาธารณะ

ข้อดี:

  • คีย์สาธารณะสามารถแบ่งปันและเผยแพร่ได้อย่างปลอดภัย
  • บริการหลายแห่งสามารถตรวจสอบ token โดยไม่ต้องแบ่งปันลับ
  • มาตรฐานอุตสาหกรรม (ใช้โดย OAuth2, OpenID Connect)

ข้อเสีย:

  • การสร้างและตรวจสอบลายเซ็นช้ากว่า
  • ขนาดคีย์ใหญ่ (2048-4096 บิต)

กรณีการใช้งาน: ระบบแบบกระจาย, Public APIs, OAuth2 providers

ES256 (ECDSA-SHA256) — อสมมาตร

เหมือน RSA แต่ใช้ elliptic curve cryptography — คีย์เล็กกว่า, การดำเนินการเร็วกว่า

ข้อดี:

  • เร็วกว่า RSA
  • คีย์เล็กกว่า (256 บิต เทียบกับ 2048 บิต)
  • แบนด์วิธต่ำกว่า

ข้อเสีย:

  • รองรับน้อยกว่า
  • เข้าใจได้ยากกว่าเล็กน้อย

กรณีการใช้งาน: ระบบประสิทธิภาพสูง, แอปมือถือ, edge computing

ส่วนที่ 3: การใช้งานแบบสมบูรณ์ — สร้าง Token

นี่คือการใช้งานที่พร้อมสำหรับการผลิตโดยใช้ Express และ TypeScript:

import jwt from 'jsonwebtoken';
import crypto from 'crypto';

// ============================================================================
// Token Configuration
// ============================================================================

interface TokenPayload {
  sub: string; // user ID
  email: string;
  role: 'user' | 'admin';
  type: 'access' | 'refresh'; // Token type
  iat?: number;
  exp?: number;
  jti?: string; // JWT ID for revocation tracking
}

const TOKEN_CONFIG = {
  access: {
    expiresIn: '15m', // Short-lived
    algorithm: 'RS256' as const,
  },
  refresh: {
    expiresIn: '7d', // Long-lived
    algorithm: 'RS256' as const,
  },
};

// ============================================================================
// Generate RSA Key Pair (run once in production, store in env)
// ============================================================================

function generateKeyPair() {
  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
    modulusLength: 2048,
    publicKeyEncoding: {
      type: 'spki',
      format: 'pem',
    },
    privateKeyEncoding: {
      type: 'pkcs8',
      format: 'pem',
    },
  });

  return { publicKey, privateKey };
}

// In production, load from environment:
const PRIVATE_KEY = process.env.JWT_PRIVATE_KEY || generateKeyPair().privateKey;
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY || generateKeyPair().publicKey;

// ============================================================================
// Token Generation
// ============================================================================

/**
 * Generate an access token (short-lived)
 */
function generateAccessToken(userId: string, email: string, role: string): string {
  const payload: TokenPayload = {
    sub: userId,
    email,
    role: role as 'user' | 'admin',
    type: 'access',
    jti: crypto.randomUUID(), // Unique token ID for revocation
  };

  return jwt.sign(payload, PRIVATE_KEY, {
    algorithm: 'RS256',
    expiresIn: '15m',
    issuer: 'api.example.com',
    audience: 'api.example.com',
  });
}

/**
 * Generate a refresh token (long-lived)
 */
function generateRefreshToken(userId: string): string {
  const payload: TokenPayload = {
    sub: userId,
    email: '', // Not needed in refresh token
    role: 'user',
    type: 'refresh',
    jti: crypto.randomUUID(),
  };

  return jwt.sign(payload, PRIVATE_KEY, {
    algorithm: 'RS256',
    expiresIn: '7d',
    issuer: 'api.example.com',
    audience: 'api.example.com',
  });
}

/**
 * Generate both access and refresh tokens
 */
function generateTokenPair(userId: string, email: string, role: string) {
  return {
    accessToken: generateAccessToken(userId, email, role),
    refreshToken: generateRefreshToken(userId),
  };
}

// ============================================================================
// Token Verification
// ============================================================================

/**
 * Verify a token and return its payload
 */
function verifyToken(token: string): TokenPayload {
  try {
    const decoded = jwt.verify(token, PUBLIC_KEY, {
      algorithms: ['RS256'],
      issuer: 'api.example.com',
      audience: 'api.example.com',
    }) as TokenPayload;

    return decoded;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new Error('Token expired');
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new Error('Invalid token');
    }
    throw error;
  }
}

// ============================================================================
// Decode without Verification (for debugging only)
// ============================================================================

function decodeToken(token: string): TokenPayload | null {
  return jwt.decode(token) as TokenPayload | null;
}

export { generateTokenPair, verifyToken, decodeToken, generateAccessToken, generateRefreshToken };
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Service;

import java.security.*;
import java.security.spec.*;
import java.util.*;

// TokenPayload record
public record TokenPayload(
    String sub,
    String email,
    String role,
    String type,
    String jti
) {}

@Service
public class JwtTokenService {

    private final PrivateKey privateKey;
    private final PublicKey publicKey;

    public JwtTokenService() throws Exception {
        // Load from environment in production
        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
        keyGen.initialize(2048);
        KeyPair pair = keyGen.generateKeyPair();
        this.privateKey = pair.getPrivate();
        this.publicKey  = pair.getPublic();
    }

    /** Generate an access token (15 min) */
    public String generateAccessToken(String userId, String email, String role) {
        return Jwts.builder()
            .subject(userId)
            .claim("email", email)
            .claim("role", role)
            .claim("type", "access")
            .id(UUID.randomUUID().toString())
            .issuer("api.example.com")
            .audience().add("api.example.com").and()
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000))
            .signWith(privateKey, Jwts.SIG.RS256)
            .compact();
    }

    /** Generate a refresh token (7 days) */
    public String generateRefreshToken(String userId) {
        return Jwts.builder()
            .subject(userId)
            .claim("type", "refresh")
            .id(UUID.randomUUID().toString())
            .issuer("api.example.com")
            .audience().add("api.example.com").and()
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + 7L * 24 * 60 * 60 * 1000))
            .signWith(privateKey, Jwts.SIG.RS256)
            .compact();
    }

    /** Generate both access and refresh tokens */
    public Map<String, String> generateTokenPair(String userId, String email, String role) {
        return Map.of(
            "accessToken",  generateAccessToken(userId, email, role),
            "refreshToken", generateRefreshToken(userId)
        );
    }

    /** Verify a token and return its claims */
    public Claims verifyToken(String token) {
        try {
            return Jwts.parser()
                .verifyWith(publicKey)
                .requireIssuer("api.example.com")
                .build()
                .parseSignedClaims(token)
                .getPayload();
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("Token expired");
        } catch (JwtException e) {
            throw new RuntimeException("Invalid token");
        }
    }

    /** Decode without verification (debugging only) */
    public Claims decodeToken(String token) {
        return (Claims) Jwts.parser().unsecured().build()
            .parse(token).getPayload();
    }
}
import jwt
import uuid
import os
from datetime import datetime, timedelta, timezone
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from dataclasses import dataclass
from typing import Optional

# ============================================================
# Token payload dataclass
# ============================================================

@dataclass
class TokenPayload:
    sub: str
    email: str
    role: str
    type: str
    jti: Optional[str] = None
    iat: Optional[int] = None
    exp: Optional[int] = None

# ============================================================
# Key generation (run once; load from env in production)
# ============================================================

def generate_key_pair():
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048,
    )
    return private_key, private_key.public_key()

_private_key, _public_key = generate_key_pair()

PRIVATE_KEY_PEM = os.getenv(
    "JWT_PRIVATE_KEY",
    _private_key.private_bytes(
        serialization.Encoding.PEM,
        serialization.PrivateFormat.PKCS8,
        serialization.NoEncryption(),
    ).decode(),
)
PUBLIC_KEY_PEM = os.getenv(
    "JWT_PUBLIC_KEY",
    _public_key.public_bytes(
        serialization.Encoding.PEM,
        serialization.PublicFormat.SubjectPublicKeyInfo,
    ).decode(),
)

# ============================================================
# Token generation
# ============================================================

def generate_access_token(user_id: str, email: str, role: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": user_id,
        "email": email,
        "role": role,
        "type": "access",
        "jti": str(uuid.uuid4()),
        "iss": "api.example.com",
        "aud": "api.example.com",
        "iat": now,
        "exp": now + timedelta(minutes=15),
    }
    return jwt.encode(payload, PRIVATE_KEY_PEM, algorithm="RS256")

def generate_refresh_token(user_id: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": user_id,
        "type": "refresh",
        "jti": str(uuid.uuid4()),
        "iss": "api.example.com",
        "aud": "api.example.com",
        "iat": now,
        "exp": now + timedelta(days=7),
    }
    return jwt.encode(payload, PRIVATE_KEY_PEM, algorithm="RS256")

def generate_token_pair(user_id: str, email: str, role: str) -> dict:
    return {
        "accessToken":  generate_access_token(user_id, email, role),
        "refreshToken": generate_refresh_token(user_id),
    }

# ============================================================
# Token verification
# ============================================================

def verify_token(token: str) -> dict:
    try:
        return jwt.decode(
            token,
            PUBLIC_KEY_PEM,
            algorithms=["RS256"],
            issuer="api.example.com",
            audience="api.example.com",
        )
    except jwt.ExpiredSignatureError:
        raise ValueError("Token expired")
    except jwt.InvalidTokenError:
        raise ValueError("Invalid token")

def decode_token(token: str) -> dict | None:
    """Decode without verification — debugging only."""
    return jwt.decode(token, options={"verify_signature": False})
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;

// ============================================================
// Token payload record
// ============================================================

public record TokenPayload(
    string Sub,
    string Email,
    string Role,
    string Type,
    string Jti
);

// ============================================================
// JWT Token Service
// ============================================================

public class JwtTokenService
{
    private readonly RsaSecurityKey _privateKey;
    private readonly RsaSecurityKey _publicKey;
    private const string Issuer   = "api.example.com";
    private const string Audience = "api.example.com";

    public JwtTokenService()
    {
        // In production, load PEM from environment variables
        var rsa = RSA.Create(2048);
        _privateKey = new RsaSecurityKey(rsa);
        _publicKey  = new RsaSecurityKey(rsa);
    }

    /// <summary>Generate an access token (15 min).</summary>
    public string GenerateAccessToken(string userId, string email, string role)
    {
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub,   userId),
            new Claim("email", email),
            new Claim("role",  role),
            new Claim("type",  "access"),
            new Claim(JwtRegisteredClaimNames.Jti,   Guid.NewGuid().ToString()),
        };
        return CreateToken(claims, TimeSpan.FromMinutes(15));
    }

    /// <summary>Generate a refresh token (7 days).</summary>
    public string GenerateRefreshToken(string userId)
    {
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, userId),
            new Claim("type", "refresh"),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        };
        return CreateToken(claims, TimeSpan.FromDays(7));
    }

    /// <summary>Generate both tokens.</summary>
    public (string AccessToken, string RefreshToken) GenerateTokenPair(
        string userId, string email, string role) =>
        (GenerateAccessToken(userId, email, role), GenerateRefreshToken(userId));

    /// <summary>Verify token and return its ClaimsPrincipal.</summary>
    public ClaimsPrincipal VerifyToken(string token)
    {
        var handler = new JwtSecurityTokenHandler();
        var parameters = new TokenValidationParameters
        {
            ValidateIssuer           = true,
            ValidIssuer              = Issuer,
            ValidateAudience         = true,
            ValidAudience            = Audience,
            ValidateLifetime         = true,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey         = _publicKey,
            ValidAlgorithms          = new[] { SecurityAlgorithms.RsaSha256 },
        };

        try
        {
            return handler.ValidateToken(token, parameters, out _);
        }
        catch (SecurityTokenExpiredException)
        {
            throw new Exception("Token expired");
        }
        catch (SecurityTokenException)
        {
            throw new Exception("Invalid token");
        }
    }

    /// <summary>Decode without verification — debugging only.</summary>
    public JwtSecurityToken? DecodeToken(string token)
    {
        var handler = new JwtSecurityTokenHandler();
        return handler.CanReadToken(token)
            ? handler.ReadJwtToken(token)
            : null;
    }

    private string CreateToken(IEnumerable<Claim> claims, TimeSpan lifetime)
    {
        var credentials = new SigningCredentials(
            _privateKey, SecurityAlgorithms.RsaSha256);

        var descriptor = new SecurityTokenDescriptor
        {
            Subject            = new ClaimsIdentity(claims),
            Issuer             = Issuer,
            Audience           = Audience,
            IssuedAt           = DateTime.UtcNow,
            Expires            = DateTime.UtcNow.Add(lifetime),
            SigningCredentials = credentials,
        };

        var handler = new JwtSecurityTokenHandler();
        return handler.WriteToken(handler.CreateToken(descriptor));
    }
}

ส่วนที่ 4: แพตเทิร์นการรีเฟรช Token พร้อมการหมุนเวียน

Access token มีอายุสั้น (15 นาที) เพื่อลดความเสียหายหากถูกบุกรุก เมื่อหมดอายุ ไคลเอนต์ใช้ refresh token เพื่อรับ access token ใหม่ เพื่อป้องกัน replay attacks เราใช้ refresh token rotation: แต่ละการรีเฟรชออกให้ refresh token ใหม่

// ============================================================================
// Refresh Token Rotation
// ============================================================================

interface RefreshTokenRequest {
  refreshToken: string;
}

interface AuthResponse {
  accessToken: string;
  refreshToken: string;
}

/**
 * POST /auth/refresh
 * Exchange refresh token for new access + refresh tokens
 */
async function refreshTokens(request: RefreshTokenRequest): Promise<AuthResponse> {
  try {
    // 1. Verify the refresh token
    const decoded = verifyToken(request.refreshToken);

    // 2. Ensure it's actually a refresh token
    if (decoded.type !== 'refresh') {
      throw new Error('Invalid token type');
    }

    // 3. Check if token is revoked (via Redis blacklist)
    const isRevoked = await isTokenRevoked(decoded.jti!);
    if (isRevoked) {
      throw new Error('Refresh token has been revoked');
    }

    // 4. Revoke the old refresh token (prevent replay)
    await revokeToken(decoded.jti!, 7 * 24 * 60 * 60); // 7-day TTL

    // 5. Fetch fresh user data from database
    const user = await db.users.findById(decoded.sub);
    if (!user) {
      throw new Error('User not found');
    }

    // 6. Issue new token pair
    const newTokens = generateTokenPair(user.id, user.email, user.role);

    return newTokens;
  } catch (error) {
    throw new Error(`Token refresh failed: ${error.message}`);
  }
}

/**
 * POST /auth/logout
 * Revoke both tokens
 */
async function logout(accessToken: string, refreshToken: string): Promise<void> {
  const accessDecoded = verifyToken(accessToken);
  const refreshDecoded = verifyToken(refreshToken);

  // Revoke both tokens
  await revokeToken(accessDecoded.jti!, 15 * 60); // Access token TTL
  await revokeToken(refreshDecoded.jti!, 7 * 24 * 60 * 60); // Refresh token TTL
}
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import io.jsonwebtoken.Claims;

@Service
public class TokenRefreshService {

    private final JwtTokenService jwtTokenService;
    private final TokenRevocationService revocationService;
    private final UserRepository userRepository;

    public TokenRefreshService(
        JwtTokenService jwtTokenService,
        TokenRevocationService revocationService,
        UserRepository userRepository
    ) {
        this.jwtTokenService   = jwtTokenService;
        this.revocationService = revocationService;
        this.userRepository    = userRepository;
    }

    /**
     * POST /auth/refresh
     * Exchange refresh token for new access + refresh tokens
     */
    @Transactional
    public Map<String, String> refreshTokens(String refreshToken) {
        // 1. Verify the refresh token
        Claims claims = jwtTokenService.verifyToken(refreshToken);

        // 2. Ensure it's a refresh token
        if (!"refresh".equals(claims.get("type", String.class))) {
            throw new IllegalArgumentException("Invalid token type");
        }

        String jti = claims.getId();

        // 3. Check if revoked
        if (revocationService.isRevoked(jti)) {
            throw new SecurityException("Refresh token has been revoked");
        }

        // 4. Revoke old refresh token
        revocationService.revoke(jti, 7 * 24 * 60 * 60);

        // 5. Fetch fresh user data
        User user = userRepository.findById(claims.getSubject())
            .orElseThrow(() -> new RuntimeException("User not found"));

        // 6. Issue new token pair
        return jwtTokenService.generateTokenPair(user.getId(), user.getEmail(), user.getRole());
    }

    /**
     * POST /auth/logout — revoke both tokens
     */
    public void logout(String accessToken, String refreshToken) {
        Claims accessClaims  = jwtTokenService.verifyToken(accessToken);
        Claims refreshClaims = jwtTokenService.verifyToken(refreshToken);

        revocationService.revoke(accessClaims.getId(),  15 * 60);
        revocationService.revoke(refreshClaims.getId(), 7 * 24 * 60 * 60);
    }
}
from fastapi import HTTPException
from pydantic import BaseModel

class RefreshTokenRequest(BaseModel):
    refresh_token: str

class AuthResponse(BaseModel):
    access_token: str
    refresh_token: str

async def refresh_tokens(request: RefreshTokenRequest) -> AuthResponse:
    """POST /auth/refresh — exchange refresh token for new token pair."""
    # 1. Verify the refresh token
    claims = verify_token(request.refresh_token)

    # 2. Ensure it is a refresh token
    if claims.get("type") != "refresh":
        raise HTTPException(status_code=401, detail="Invalid token type")

    jti = claims["jti"]

    # 3. Check revocation
    if await is_token_revoked(jti):
        raise HTTPException(status_code=401, detail="Refresh token has been revoked")

    # 4. Revoke old refresh token (prevent replay)
    await revoke_token(jti, ttl_seconds=7 * 24 * 60 * 60)

    # 5. Fetch fresh user data
    user = await db.users.find_by_id(claims["sub"])
    if not user:
        raise HTTPException(status_code=401, detail="User not found")

    # 6. Issue new token pair
    tokens = generate_token_pair(user.id, user.email, user.role)
    return AuthResponse(
        access_token=tokens["accessToken"],
        refresh_token=tokens["refreshToken"],
    )

async def logout(access_token: str, refresh_token: str) -> None:
    """POST /auth/logout — revoke both tokens."""
    access_claims  = verify_token(access_token)
    refresh_claims = verify_token(refresh_token)

    await revoke_token(access_claims["jti"],  ttl_seconds=15 * 60)
    await revoke_token(refresh_claims["jti"], ttl_seconds=7 * 24 * 60 * 60)
using Microsoft.AspNetCore.Mvc;

public class RefreshTokenRequest
{
    public string RefreshToken { get; set; } = "";
}

public class AuthResponse
{
    public string AccessToken  { get; set; } = "";
    public string RefreshToken { get; set; } = "";
}

[ApiController]
[Route("auth")]
public class TokenRefreshController : ControllerBase
{
    private readonly JwtTokenService       _jwtService;
    private readonly TokenRevocationService _revocation;
    private readonly IUserRepository        _users;

    public TokenRefreshController(
        JwtTokenService jwtService,
        TokenRevocationService revocation,
        IUserRepository users)
    {
        _jwtService  = jwtService;
        _revocation  = revocation;
        _users       = users;
    }

    /// <summary>POST /auth/refresh — exchange refresh token for new pair.</summary>
    [HttpPost("refresh")]
    public async Task<IActionResult> RefreshTokens([FromBody] RefreshTokenRequest request)
    {
        // 1. Verify refresh token
        var principal = _jwtService.VerifyToken(request.RefreshToken);
        var tokenType = principal.FindFirstValue("type");

        // 2. Ensure it is a refresh token
        if (tokenType != "refresh")
            return Unauthorized(new { error = "Invalid token type" });

        var jti = principal.FindFirstValue(JwtRegisteredClaimNames.Jti)!;

        // 3. Check revocation
        if (await _revocation.IsRevokedAsync(jti))
            return Unauthorized(new { error = "Refresh token has been revoked" });

        // 4. Revoke old token
        await _revocation.RevokeAsync(jti, TimeSpan.FromDays(7));

        // 5. Fetch fresh user data
        var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier)!;
        var user   = await _users.FindByIdAsync(userId)
                     ?? throw new Exception("User not found");

        // 6. Issue new token pair
        var (accessToken, refreshToken) = _jwtService.GenerateTokenPair(
            user.Id, user.Email, user.Role);

        return Ok(new AuthResponse
        {
            AccessToken  = accessToken,
            RefreshToken = refreshToken,
        });
    }

    /// <summary>POST /auth/logout — revoke both tokens.</summary>
    [HttpPost("logout")]
    public async Task<IActionResult> Logout(
        [FromHeader(Name = "Authorization")] string authHeader,
        [FromBody] RefreshTokenRequest request)
    {
        var accessToken = authHeader.Replace("Bearer ", "");
        var accessPrincipal  = _jwtService.VerifyToken(accessToken);
        var refreshPrincipal = _jwtService.VerifyToken(request.RefreshToken);

        await _revocation.RevokeAsync(
            accessPrincipal.FindFirstValue(JwtRegisteredClaimNames.Jti)!,
            TimeSpan.FromMinutes(15));

        await _revocation.RevokeAsync(
            refreshPrincipal.FindFirstValue(JwtRegisteredClaimNames.Jti)!,
            TimeSpan.FromDays(7));

        return Ok(new { message = "Logged out" });
    }
}

ส่วนที่ 5: Auth Middleware สำหรับ Protected Routes

นี่คือ middleware ของ Express ที่ตรวจสอบ JWT และแนบข้อมูลผู้ใช้กับคำขอ:

import { Request, Response, NextFunction } from 'express';

// Extend Express Request type
declare global {
  namespace Express {
    interface Request {
      user?: TokenPayload;
    }
  }
}

/**
 * Middleware: Extract and verify JWT from Authorization header
 */
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
  try {
    const authHeader = req.headers.authorization;
    
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'Missing or invalid authorization header' });
    }

    const token = authHeader.slice(7); // Remove "Bearer " prefix

    // Verify token
    const payload = verifyToken(token);

    // Check token type
    if (payload.type !== 'access') {
      return res.status(401).json({ error: 'Invalid token type' });
    }

    // Attach user to request
    req.user = payload;
    next();
  } catch (error) {
    return res.status(401).json({ error: error.message });
  }
}

/**
 * Middleware: Check user role
 */
export function requireRole(...roles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    next();
  };
}

// ============================================================================
// Example Route
// ============================================================================

app.get('/api/profile', authMiddleware, (req: Request, res: Response) => {
  // req.user is now available and verified
  res.json({
    userId: req.user!.sub,
    email: req.user!.email,
    role: req.user!.role,
  });
});

app.delete('/api/admin/users/:id', authMiddleware, requireRole('admin'), (req: Request, res: Response) => {
  // Only admin users can reach here
  res.json({ message: 'User deleted' });
});
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.springframework.security.authentication.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.Claims;

@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtTokenService jwtTokenService;

    public JwtAuthFilter(JwtTokenService jwtTokenService) {
        this.jwtTokenService = jwtTokenService;
    }

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain
    ) throws ServletException, java.io.IOException {

        String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);

        try {
            Claims claims = jwtTokenService.verifyToken(token);

            if (!"access".equals(claims.get("type", String.class))) {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token type");
                return;
            }

            // Build Spring Security authentication object
            var auth = new UsernamePasswordAuthenticationToken(
                claims.getSubject(),
                null,
                List.of(new SimpleGrantedAuthority("ROLE_" + claims.get("role", String.class).toUpperCase()))
            );
            SecurityContextHolder.getContext().setAuthentication(auth);
            chain.doFilter(request, response);

        } catch (Exception e) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
        }
    }
}

// Role-based access via Spring Security annotations:
@RestController
@RequestMapping("/api")
public class ProfileController {

    @GetMapping("/profile")
    public ResponseEntity<?> getProfile(Authentication auth) {
        return ResponseEntity.ok(Map.of(
            "userId", auth.getName(),
            "role",   auth.getAuthorities().iterator().next().getAuthority()
        ));
    }

    @DeleteMapping("/admin/users/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<?> deleteUser(@PathVariable String id) {
        return ResponseEntity.ok(Map.of("message", "User deleted"));
    }
}
from fastapi import Depends, HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

bearer_scheme = HTTPBearer()

async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Security(bearer_scheme),
) -> dict:
    """FastAPI dependency: verify JWT and return payload."""
    token = credentials.credentials
    try:
        payload = verify_token(token)
    except ValueError as exc:
        raise HTTPException(status_code=401, detail=str(exc))

    if payload.get("type") != "access":
        raise HTTPException(status_code=401, detail="Invalid token type")

    return payload

def require_role(*roles: str):
    """Dependency factory for role-based access."""
    async def check(user: dict = Depends(get_current_user)):
        if user.get("role") not in roles:
            raise HTTPException(status_code=403, detail="Insufficient permissions")
        return user
    return check

# ---- Example routes ----

from fastapi import FastAPI
app = FastAPI()

@app.get("/api/profile")
async def get_profile(user: dict = Depends(get_current_user)):
    return {"userId": user["sub"], "email": user["email"], "role": user["role"]}

@app.delete("/api/admin/users/{user_id}")
async def delete_user(
    user_id: str,
    _admin: dict = Depends(require_role("admin")),
):
    return {"message": "User deleted"}
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.IdentityModel.Tokens.Jwt;

// --- Middleware ---

public class JwtAuthMiddleware
{
    private readonly RequestDelegate _next;
    private readonly JwtTokenService _jwtService;

    public JwtAuthMiddleware(RequestDelegate next, JwtTokenService jwtService)
    {
        _next       = next;
        _jwtService = jwtService;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();

        if (authHeader?.StartsWith("Bearer ") == true)
        {
            var token = authHeader["Bearer ".Length..];
            try
            {
                var principal = _jwtService.VerifyToken(token);
                var tokenType = principal.FindFirstValue("type");

                if (tokenType == "access")
                    context.User = principal;
                else
                {
                    context.Response.StatusCode = 401;
                    await context.Response.WriteAsJsonAsync(new { error = "Invalid token type" });
                    return;
                }
            }
            catch
            {
                context.Response.StatusCode = 401;
                await context.Response.WriteAsJsonAsync(new { error = "Invalid token" });
                return;
            }
        }

        await _next(context);
    }
}

// --- Controllers using [Authorize] ---

[ApiController]
[Route("api")]
public class ProfileController : ControllerBase
{
    [HttpGet("profile")]
    [Authorize]
    public IActionResult GetProfile()
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var role   = User.FindFirstValue("role");
        return Ok(new { userId, role });
    }

    [HttpDelete("admin/users/{id}")]
    [Authorize(Roles = "admin")]
    public IActionResult DeleteUser(string id)
    {
        return Ok(new { message = "User deleted" });
    }
}

ส่วนที่ 6: การเปรียบเทียบเก็บ Token

ตำแหน่งที่คุณเก็บ token ที่ฝั่งไคลเอนต์มีผลต่อความปลอดภัยอย่างมาก:

วิธีการเก็บความปลอดภัยการเข้าถึงความเสี่ยง XSSความเสี่ยง CSRFหมายเหตุ
httpOnly Cookie✅ สูง✅ อัตโนมัติ✅ ปลอดภัย⚠️ เสี่ยงดีที่สุดสำหรับแอปเว็บ ป้องกัน XSS แต่เสี่ยง CSRF ใช้ SameSite attribute
localStorage⚠️ ปานกลาง✅ ง่าย❌ ไม่ปลอดภัย✅ ปลอดภัยเสี่ยงต่อ XSS injection JavaScript สามารถอ่านได้ หลีกเลี่ยงการเก็บ token ที่ไวต่อข้อมูล
sessionStorage⚠️ ปานกลาง✅ ง่าย❌ ไม่ปลอดภัย✅ ปลอดภัยเหมือน localStorage ล้างข้อมูลเมื่อปิดแท็บ เสี่ยง XSS อยู่ดี
Memory✅ สูง⚠️ หายไปเมื่อรีเฟรช✅ ปลอดภัย✅ ปลอดภัยตัวเลือกที่ปลอดภัยที่สุด Token หายไปเมื่อรีเฟรชหน้า — ต้อง silent refresh pattern
/**
 * Set token in httpOnly cookie
 */
function setTokenCookie(res: Response, token: string, maxAge: number) {
  res.cookie('accessToken', token, {
    httpOnly: true,          // Inaccessible to JavaScript
    secure: true,            // HTTPS only
    sameSite: 'strict',      // CSRF protection
    maxAge: maxAge * 1000,   // Milliseconds
    path: '/',
  });
}

/**
 * Example login route
 */
app.post('/auth/login', async (req: Request, res: Response) => {
  const { email, password } = req.body;

  // Authenticate user...
  const user = await authenticateUser(email, password);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Generate tokens
  const { accessToken, refreshToken } = generateTokenPair(user.id, user.email, user.role);

  // Set access token in httpOnly cookie (15m)
  setTokenCookie(res, accessToken, 15 * 60);

  // Set refresh token in httpOnly cookie (7d)
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000,
    path: '/',
  });

  res.json({
    user: {
      id: user.id,
      email: user.email,
      role: user.role,
    },
  });
});

/**
 * Middleware: Extract JWT from httpOnly cookie
 */
export function cookieAuthMiddleware(req: Request, res: Response, next: NextFunction) {
  try {
    const token = req.cookies.accessToken;

    if (!token) {
      return res.status(401).json({ error: 'No token provided' });
    }

    const payload = verifyToken(token);
    req.user = payload;
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final JwtTokenService jwtTokenService;
    private final UserService     userService;

    public AuthController(JwtTokenService jwtTokenService, UserService userService) {
        this.jwtTokenService = jwtTokenService;
        this.userService     = userService;
    }

    private void setTokenCookie(
        HttpServletResponse response,
        String name,
        String value,
        int maxAgeSeconds
    ) {
        Cookie cookie = new Cookie(name, value);
        cookie.setHttpOnly(true);
        cookie.setSecure(true);
        cookie.setPath("/");
        cookie.setMaxAge(maxAgeSeconds);
        // SameSite requires ResponseCookie in Spring:
        response.addHeader("Set-Cookie",
            String.format("%s=%s; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=%d",
                name, value, maxAgeSeconds));
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(
        @RequestBody LoginRequest body,
        HttpServletResponse response
    ) {
        User user = userService.authenticate(body.email(), body.password());
        if (user == null) {
            return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
        }

        var tokens = jwtTokenService.generateTokenPair(user.getId(), user.getEmail(), user.getRole());

        setTokenCookie(response, "accessToken",  tokens.get("accessToken"),  15 * 60);
        setTokenCookie(response, "refreshToken", tokens.get("refreshToken"), 7 * 24 * 60 * 60);

        return ResponseEntity.ok(Map.of("user", Map.of(
            "id",    user.getId(),
            "email", user.getEmail(),
            "role",  user.getRole()
        )));
    }
}

// Cookie-based JWT filter — reads from cookie instead of Authorization header
@Component
public class CookieJwtAuthFilter extends OncePerRequestFilter {
    private final JwtTokenService jwtTokenService;

    public CookieJwtAuthFilter(JwtTokenService jwtTokenService) {
        this.jwtTokenService = jwtTokenService;
    }

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain
    ) throws ServletException, java.io.IOException {
        if (request.getCookies() != null) {
            Arrays.stream(request.getCookies())
                .filter(c -> "accessToken".equals(c.getName()))
                .findFirst()
                .ifPresent(cookie -> {
                    try {
                        Claims claims = jwtTokenService.verifyToken(cookie.getValue());
                        var auth = new UsernamePasswordAuthenticationToken(
                            claims.getSubject(), null,
                            List.of(new SimpleGrantedAuthority(
                                "ROLE_" + claims.get("role", String.class).toUpperCase())));
                        SecurityContextHolder.getContext().setAuthentication(auth);
                    } catch (Exception ignored) {}
                });
        }
        chain.doFilter(request, response);
    }
}
from fastapi import FastAPI, Request, Response, HTTPException, Depends
from fastapi.responses import JSONResponse

app = FastAPI()

def set_token_cookie(response: Response, name: str, value: str, max_age: int) -> None:
    response.set_cookie(
        key=name,
        value=value,
        httponly=True,
        secure=True,
        samesite="strict",
        max_age=max_age,
        path="/",
    )

@app.post("/auth/login")
async def login(body: dict, response: Response):
    user = await authenticate_user(body["email"], body["password"])
    if not user:
        raise HTTPException(status_code=401, detail="Invalid credentials")

    tokens = generate_token_pair(user.id, user.email, user.role)

    set_token_cookie(response, "accessToken",  tokens["accessToken"],  15 * 60)
    set_token_cookie(response, "refreshToken", tokens["refreshToken"], 7 * 24 * 60 * 60)

    return {"user": {"id": user.id, "email": user.email, "role": user.role}}

async def cookie_auth(request: Request) -> dict:
    """Dependency: extract and verify JWT from httpOnly cookie."""
    token = request.cookies.get("accessToken")
    if not token:
        raise HTTPException(status_code=401, detail="No token provided")
    try:
        return verify_token(token)
    except ValueError:
        raise HTTPException(status_code=401, detail="Invalid token")

@app.get("/api/profile")
async def get_profile(user: dict = Depends(cookie_auth)):
    return {"userId": user["sub"], "email": user["email"]}
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("auth")]
public class CookieAuthController : ControllerBase
{
    private readonly JwtTokenService _jwtService;
    private readonly IUserService    _userService;

    public CookieAuthController(JwtTokenService jwtService, IUserService userService)
    {
        _jwtService  = jwtService;
        _userService = userService;
    }

    private void SetTokenCookie(string name, string value, int maxAgeSeconds)
    {
        Response.Cookies.Append(name, value, new CookieOptions
        {
            HttpOnly  = true,
            Secure    = true,
            SameSite  = SameSiteMode.Strict,
            MaxAge    = TimeSpan.FromSeconds(maxAgeSeconds),
            Path      = "/",
        });
    }

    [HttpPost("login")]
    public async Task<IActionResult> Login([FromBody] LoginRequest body)
    {
        var user = await _userService.AuthenticateAsync(body.Email, body.Password);
        if (user is null)
            return Unauthorized(new { error = "Invalid credentials" });

        var (accessToken, refreshToken) = _jwtService.GenerateTokenPair(
            user.Id, user.Email, user.Role);

        SetTokenCookie("accessToken",  accessToken,  15 * 60);
        SetTokenCookie("refreshToken", refreshToken, 7 * 24 * 60 * 60);

        return Ok(new { user = new { user.Id, user.Email, user.Role } });
    }
}

// Cookie-based JWT middleware
public class CookieJwtMiddleware
{
    private readonly RequestDelegate _next;
    private readonly JwtTokenService _jwtService;

    public CookieJwtMiddleware(RequestDelegate next, JwtTokenService jwtService)
    {
        _next       = next;
        _jwtService = jwtService;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (context.Request.Cookies.TryGetValue("accessToken", out var token))
        {
            try
            {
                context.User = _jwtService.VerifyToken(token);
            }
            catch { /* invalid token — leave context.User as anonymous */ }
        }
        await _next(context);
    }
}

ส่วนที่ 7: กลยุทธ์การเพิกถอน Token

JWT เป็น stateless แต่บางครั้งคุณต้องเพิกถอนทันที (logout, เปลี่ยนรหัสผ่าน, เปลี่ยนบทบาท) มีสามกลยุทธ์:

กลยุทธ์ 1: Token Blacklist กับ Redis

เก็บ ID token ที่ถูกเพิกถอนใน Redis โดยมี TTL เท่ากับการหมดอายุของ token

import redis from 'redis';

const redisClient = redis.createClient({
  url: process.env.REDIS_URL,
});

/**
 * Revoke a token by adding its JTI to the blacklist
 */
async function revokeToken(jti: string, ttlSeconds: number): Promise<void> {
  await redisClient.setEx(
    `jwt:blacklist:${jti}`,
    ttlSeconds,
    'revoked'
  );
}

/**
 * Check if a token is revoked
 */
async function isTokenRevoked(jti: string): Promise<boolean> {
  const result = await redisClient.get(`jwt:blacklist:${jti}`);
  return result !== null;
}

/**
 * Enhanced verifyToken with blacklist check
 */
async function verifyTokenWithRevocation(token: string): Promise<TokenPayload> {
  const payload = verifyToken(token); // Initial verification

  // Check if token is revoked
  if (payload.jti && await isTokenRevoked(payload.jti)) {
    throw new Error('Token has been revoked');
  }

  return payload;
}
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;

@Service
public class TokenRevocationService {

    private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
    private final StringRedisTemplate redis;

    public TokenRevocationService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    /** Revoke a token by adding its JTI to the blacklist. */
    public void revoke(String jti, long ttlSeconds) {
        redis.opsForValue().set(
            BLACKLIST_PREFIX + jti,
            "revoked",
            Duration.ofSeconds(ttlSeconds)
        );
    }

    /** Check whether a token is revoked. */
    public boolean isRevoked(String jti) {
        return Boolean.TRUE.equals(redis.hasKey(BLACKLIST_PREFIX + jti));
    }

    /** Verify token and check revocation. */
    public Claims verifyTokenWithRevocation(String token, JwtTokenService jwtService) {
        Claims payload = jwtService.verifyToken(token);
        String jti     = payload.getId();

        if (jti != null && isRevoked(jti)) {
            throw new SecurityException("Token has been revoked");
        }
        return payload;
    }
}
import redis.asyncio as aioredis
import os

redis_client = aioredis.from_url(
    os.getenv("REDIS_URL", "redis://localhost:6379")
)

BLACKLIST_PREFIX = "jwt:blacklist:"

async def revoke_token(jti: str, ttl_seconds: int) -> None:
    """Add a token JTI to the Redis blacklist."""
    await redis_client.setex(f"{BLACKLIST_PREFIX}{jti}", ttl_seconds, "revoked")

async def is_token_revoked(jti: str) -> bool:
    """Return True if the token has been revoked."""
    result = await redis_client.get(f"{BLACKLIST_PREFIX}{jti}")
    return result is not None

async def verify_token_with_revocation(token: str) -> dict:
    """Verify token and check Redis blacklist."""
    payload = verify_token(token)
    jti = payload.get("jti")

    if jti and await is_token_revoked(jti):
        raise ValueError("Token has been revoked")

    return payload
using Microsoft.Extensions.Caching.StackExchangeRedis;
using StackExchange.Redis;

public class TokenRevocationService
{
    private readonly IConnectionMultiplexer _redis;
    private const string Prefix = "jwt:blacklist:";

    public TokenRevocationService(IConnectionMultiplexer redis)
    {
        _redis = redis;
    }

    /// <summary>Add a JTI to the Redis blacklist.</summary>
    public async Task RevokeAsync(string jti, TimeSpan ttl)
    {
        var db = _redis.GetDatabase();
        await db.StringSetAsync(Prefix + jti, "revoked", ttl);
    }

    /// <summary>Return true if the token is revoked.</summary>
    public async Task<bool> IsRevokedAsync(string jti)
    {
        var db = _redis.GetDatabase();
        return await db.KeyExistsAsync(Prefix + jti);
    }

    /// <summary>Verify token and check revocation.</summary>
    public async Task<ClaimsPrincipal> VerifyTokenWithRevocationAsync(
        string token, JwtTokenService jwtService)
    {
        var principal = jwtService.VerifyToken(token);
        var jti       = principal.FindFirstValue(JwtRegisteredClaimNames.Jti);

        if (jti is not null && await IsRevokedAsync(jti))
            throw new SecurityTokenException("Token has been revoked");

        return principal;
    }
}

ข้อดี: การเพิกถอนทันที, ควบคุมอย่างละเอียด ข้อเสีย: ต้อง Redis, ขัดแย้งกับข้อดีของ stateless

กลยุทธ์ 2: Token Versioning

เพิ่มหมายเลขเวอร์ชั่นเมื่อสิทธิ์ของผู้ใช้เปลี่ยนแปลง รวมเวอร์ชั่นใน token

interface TokenPayload {
  sub: string;
  version: number; // User's current version
  // ...
}

/**
 * When user updates password or role, increment their version
 */
async function incrementUserVersion(userId: string): Promise<number> {
  const user = await db.users.findById(userId);
  const newVersion = (user.tokenVersion || 0) + 1;
  await db.users.update(userId, { tokenVersion: newVersion });
  return newVersion;
}

/**
 * Verify token version matches current user version
 */
async function verifyTokenVersion(payload: TokenPayload): Promise<void> {
  const user = await db.users.findById(payload.sub);
  if (user.tokenVersion !== payload.version) {
    throw new Error('Token version mismatch—user has revoked tokens');
  }
}
import org.springframework.stereotype.Service;

@Service
public class TokenVersionService {

    private final UserRepository userRepository;

    public TokenVersionService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    /** Increment token version — invalidates all existing tokens for this user. */
    public int incrementUserVersion(String userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("User not found"));
        int newVersion = (user.getTokenVersion() == null ? 0 : user.getTokenVersion()) + 1;
        user.setTokenVersion(newVersion);
        userRepository.save(user);
        return newVersion;
    }

    /** Verify the token version matches the stored version. */
    public void verifyTokenVersion(Claims claims) {
        int tokenVersion = claims.get("version", Integer.class);
        User user = userRepository.findById(claims.getSubject())
            .orElseThrow(() -> new RuntimeException("User not found"));

        if (!Objects.equals(user.getTokenVersion(), tokenVersion)) {
            throw new SecurityException("Token version mismatch — user has revoked tokens");
        }
    }
}
from dataclasses import dataclass

@dataclass
class TokenPayload:
    sub: str
    version: int
    # ...

async def increment_user_version(user_id: str) -> int:
    """Increment token version, invalidating all existing tokens for the user."""
    user = await db.users.find_by_id(user_id)
    new_version = (user.token_version or 0) + 1
    await db.users.update(user_id, {"token_version": new_version})
    return new_version

async def verify_token_version(payload: dict) -> None:
    """Raise if the token version does not match the current user version."""
    user = await db.users.find_by_id(payload["sub"])
    if user.token_version != payload.get("version"):
        raise ValueError("Token version mismatch — user has revoked tokens")
public class TokenVersionService
{
    private readonly IUserRepository _users;

    public TokenVersionService(IUserRepository users)
    {
        _users = users;
    }

    /// <summary>Increment token version, invalidating all existing tokens.</summary>
    public async Task<int> IncrementUserVersionAsync(string userId)
    {
        var user = await _users.FindByIdAsync(userId)
                   ?? throw new Exception("User not found");

        user.TokenVersion = (user.TokenVersion ?? 0) + 1;
        await _users.UpdateAsync(user);
        return user.TokenVersion.Value;
    }

    /// <summary>Verify the token version matches the stored user version.</summary>
    public async Task VerifyTokenVersionAsync(ClaimsPrincipal principal)
    {
        var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier)!;
        var tokenVersion = int.Parse(principal.FindFirstValue("version") ?? "0");

        var user = await _users.FindByIdAsync(userId)
                   ?? throw new Exception("User not found");

        if (user.TokenVersion != tokenVersion)
            throw new SecurityTokenException("Token version mismatch — user has revoked tokens");
    }
}

ข้อดี: เรียบง่าย, ไม่ต้องจัดเก็บภายนอก, ทันที ข้อเสีย: Token เก่าทั้งหมดจะถูกเพิกถอนสำหรับผู้ใช้นั้น, ไม่ละเอียด

กลยุทธ์ 3: Short Expiry

ยอมรับว่า token บางตัวยังคงมีผลบังคับใช้สั้นๆ หลังจากการเพิกถอน ใช้ TTL สั้น (15 นาทีสำหรับ access token)

// No additional code needed—just use short-lived tokens
// Combined with refresh token rotation, this provides good coverage
// No additional code needed—just configure short expiry in JwtTokenService
// Access token: 15 minutes, refresh token: 7 days
// Combined with refresh token rotation, this provides good coverage
# No additional code needed—just use short-lived tokens
# Access token: timedelta(minutes=15), refresh token: timedelta(days=7)
# Combined with refresh token rotation, this provides good coverage
// No additional code needed—just configure short expiry in JwtTokenService
// Access token: TimeSpan.FromMinutes(15), refresh: TimeSpan.FromDays(7)
// Combined with refresh token rotation, this provides good coverage

ข้อดี: Stateless, เรียบง่าย, ทำงานได้ดีในทางปฏิบัติ ข้อเสีย: มีช่วงเวลาสั้นที่ token ถูกเพิกถอนแล้ว ยังมีผลบังคับใช้

ข้อแนะนำ: ใช้ short expiry สำหรับ access token + refresh token rotation เพิ่ม Redis blacklist เฉพาะเมื่อคุณต้อง immediate logout ทั่วทุกอุปกรณ์

ส่วนที่ 8: ข้อผิดพลาดด้านความปลอดภัยของ JWT ทั่วไป

❌ ข้อผิดพลาด 1: การเก็บข้อมูลส่วนบุคคลใน Payload

// DON'T DO THIS
const payload = {
  sub: user.id,
  email: user.email,
  password: user.passwordHash, // ❌ Storing password!
  ssn: '123-45-6789',            // ❌ PII in token!
  creditCard: '4111-1111-1111-1111', // ❌ Financial data!
};
// DON'T DO THIS
Map<String, Object> claims = new HashMap<>();
claims.put("sub",        user.getId());
claims.put("email",      user.getEmail());
claims.put("password",   user.getPasswordHash()); // ❌ Storing password!
claims.put("ssn",        "123-45-6789");           // ❌ PII in token!
claims.put("creditCard", "4111-1111-1111-1111");   // ❌ Financial data!
# DON'T DO THIS
payload = {
    "sub":         user.id,
    "email":       user.email,
    "password":    user.password_hash,   # ❌ Storing password!
    "ssn":         "123-45-6789",        # ❌ PII in token!
    "credit_card": "4111-1111-1111-1111", # ❌ Financial data!
}
// DON'T DO THIS
var claims = new[]
{
    new Claim(ClaimTypes.NameIdentifier, user.Id),
    new Claim("email",       user.Email),
    new Claim("password",    user.PasswordHash),   // ❌ Storing password!
    new Claim("ssn",         "123-45-6789"),       // ❌ PII in token!
    new Claim("creditCard",  "4111-1111-1111-1111"), // ❌ Financial data!
};

เพราะ: JWT ถูกเข้ารหัส ไม่ได้ถูกเข้ารหัส ใครก็ได้สามารถถอดรหัส payload ได้ ข้อมูลนี้จะถูกเปิดเผยในบันทึก, ประวัติเบราว์เซอร์ และแคช CDN

วิธีแก้: เก็บเฉพาะตัวระบุเท่านั้น ดึงข้อมูลที่ไวต่อข้อมูลจากฐานข้อมูลเมื่อจำเป็น

// DO THIS
const payload = {
  sub: user.id,        // User ID only
  role: user.role,     // Non-sensitive metadata
  type: 'access',
};

// When you need user email in a protected route:
app.get('/api/profile', authMiddleware, async (req: Request, res: Response) => {
  const user = await db.users.findById(req.user!.sub);
  res.json({ email: user.email }); // Fetch from DB
});
// DO THIS — store only identifiers
Map<String, Object> claims = new HashMap<>();
claims.put("sub",  user.getId());
claims.put("role", user.getRole());
claims.put("type", "access");

// Fetch sensitive data on demand
@GetMapping("/api/profile")
public ResponseEntity<?> getProfile(Authentication auth) {
    User user = userRepository.findById(auth.getName())
        .orElseThrow();
    return ResponseEntity.ok(Map.of("email", user.getEmail()));
}
# DO THIS — store only identifiers
payload = {
    "sub":  user.id,
    "role": user.role,
    "type": "access",
}

# Fetch sensitive data on demand
@app.get("/api/profile")
async def get_profile(current_user: dict = Depends(get_current_user)):
    user = await db.users.find_by_id(current_user["sub"])
    return {"email": user.email}  # Fetch from DB
// DO THIS — store only identifiers
var claims = new[]
{
    new Claim(ClaimTypes.NameIdentifier, user.Id),
    new Claim("role", user.Role),
    new Claim("type", "access"),
};

// Fetch sensitive data on demand
[HttpGet("api/profile")]
[Authorize]
public async Task<IActionResult> GetProfile()
{
    var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!;
    var user   = await _users.FindByIdAsync(userId);
    return Ok(new { email = user!.Email }); // Fetch from DB
}

❌ ข้อผิดพลาด 2: Algorithm Confusion Attack

ผู้โจมตีเปลี่ยน alg จาก RS256 เป็น HS256 จากนั้นเซ็นต์ด้วยคีย์สาธารณะ (ซึ่งเข้าถึงได้)

// Vulnerable: accepts any algorithm
jwt.verify(token, publicKey); // ❌

// Safe: whitelist algorithms
jwt.verify(token, publicKey, {
  algorithms: ['RS256'], // ✅ Only RS256 accepted
});
// Vulnerable: no algorithm whitelist
Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token); // relies on header alg ❌

// Safe: enforce RS256 explicitly
Jwts.parser()
    .verifyWith(publicKey)
    .build()
    .parseSignedClaims(token); // jjwt 0.12+ ignores "none" and HS256 when PublicKey is supplied ✅
// Extra safety — validate the algorithm claim manually:
String alg = (String) Jwts.parser().unsecured().build()
    .parse(token).getHeader().get("alg");
if (!"RS256".equals(alg)) throw new SecurityException("Algorithm not allowed");
# Vulnerable: accepts any algorithm
jwt.decode(token, public_key)  # ❌

# Safe: whitelist algorithms
jwt.decode(
    token,
    PUBLIC_KEY_PEM,
    algorithms=["RS256"],  # ✅ Only RS256 accepted
)
// Vulnerable: no algorithm whitelist
var parameters = new TokenValidationParameters
{
    IssuerSigningKey = publicKey,
    // Missing ValidAlgorithms ❌
};

// Safe: enforce RS256
var safeParameters = new TokenValidationParameters
{
    IssuerSigningKey  = publicKey,
    ValidAlgorithms   = new[] { SecurityAlgorithms.RsaSha256 }, // ✅
};

❌ ข้อผิดพลาด 3: ใช้ลับอ่อนแอกับ HS256

// ❌ WEAK
const secret = 'secret123';
const token = jwt.sign(payload, secret, { algorithm: 'HS256' });

// ✅ STRONG (use a cryptographically secure random string)
const secret = crypto.randomBytes(32).toString('hex');
// ❌ WEAK
SecretKey weakKey = Keys.hmacShaKeyFor("secret123".getBytes());

// ✅ STRONG — at least 256 bits of random data
SecretKey strongKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
// Or load from env:
// SecretKey strongKey = Keys.hmacShaKeyFor(
//     Base64.getDecoder().decode(System.getenv("JWT_SECRET")));
# ❌ WEAK
secret = "secret123"
token  = jwt.encode(payload, secret, algorithm="HS256")

# ✅ STRONG — at least 32 bytes of random data
import secrets
strong_secret = secrets.token_hex(32)
// ❌ WEAK
var weakKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("secret123"));

// ✅ STRONG — 256-bit random key
var strongKeyBytes = new byte[32];
RandomNumberGenerator.Fill(strongKeyBytes);
var strongKey = new SymmetricSecurityKey(strongKeyBytes);

กับ HS256 ลับต้องเข้มแข็งทางการเข้ารหัส ใช้อย่างน้อย 32 ไบต์ของข้อมูลสุ่ม

❌ ข้อผิดพลาด 4: ไม่ตรวจสอบการหมดอายุของ Token

// ❌ Manual expiry check (incomplete)
const payload = jwt.decode(token); // Doesn't verify expiry!
if (payload.exp < Date.now() / 1000) {
  // Too late—token already used!
}

// ✅ jwt.verify handles expiry automatically
jwt.verify(token, secret); // Throws if expired
// ❌ Manual expiry check — unreliable
Claims claims = Jwts.parser().unsecured().build()
    .parse(token).getPayload(); // No expiry check!
if (claims.getExpiration().before(new Date())) {
    // Token was already used before this check
}

// ✅ jjwt verifies expiry automatically during parseSignedClaims
Claims verified = Jwts.parser()
    .verifyWith(publicKey)
    .build()
    .parseSignedClaims(token)  // Throws ExpiredJwtException if expired ✅
    .getPayload();
# ❌ Manual expiry check — unreliable
payload = jwt.decode(token, options={"verify_exp": False})  # Skips expiry!
if payload["exp"] < time.time():
    pass  # Token already used before this check

# ✅ PyJWT verifies expiry by default
payload = jwt.decode(token, PUBLIC_KEY_PEM, algorithms=["RS256"])  # Raises ExpiredSignatureError ✅
// ❌ Manual expiry — handler reads token but does not validate
var handler = new JwtSecurityTokenHandler();
var insecure = handler.ReadJwtToken(token);  // No expiry validation!
if (insecure.ValidTo < DateTime.UtcNow) { /* already used */ }

// ✅ ValidateToken checks expiry automatically
handler.ValidateToken(token, new TokenValidationParameters
{
    ValidateLifetime = true,   // ✅ Throws SecurityTokenExpiredException if expired
    IssuerSigningKey = publicKey,
}, out _);

❌ ข้อผิดพลาด 5: ละเว้น exp Claim

// ❌ DON'T create tokens without expiration
jwt.sign(payload, secret); // No expiry!

// ✅ Always set expiration
jwt.sign(payload, secret, {
  expiresIn: '15m', // Access token
});
// ❌ No expiration
Jwts.builder()
    .subject(userId)
    .signWith(privateKey, Jwts.SIG.RS256)
    .compact(); // No expiry! ❌

// ✅ Always set expiration
Jwts.builder()
    .subject(userId)
    .expiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000)) // ✅
    .signWith(privateKey, Jwts.SIG.RS256)
    .compact();
# ❌ No expiration
payload = {"sub": user_id}
jwt.encode(payload, secret, algorithm="HS256")  # No expiry! ❌

# ✅ Always set expiration
from datetime import datetime, timedelta, timezone
payload = {
    "sub": user_id,
    "exp": datetime.now(timezone.utc) + timedelta(minutes=15),  # ✅
}
jwt.encode(payload, secret, algorithm="HS256")
// ❌ No expiration
var descriptor = new SecurityTokenDescriptor
{
    Subject            = new ClaimsIdentity(claims),
    SigningCredentials = creds,
    // Missing Expires ❌
};

// ✅ Always set expiration
var safeDescriptor = new SecurityTokenDescriptor
{
    Subject            = new ClaimsIdentity(claims),
    Expires            = DateTime.UtcNow.AddMinutes(15), // ✅
    SigningCredentials = creds,
};

❌ ข้อผิดพลาด 6: เก็บ Token ในพารามิเตอร์ URL

// ❌ BAD
window.location.href = `/callback?token=${jwtToken}`;
// Token appears in:
// - Browser history
// - Server logs
// - Referrer headers
// - CDN logs

// ✅ GOOD: Use httpOnly cookies or POST request
// ❌ BAD — redirecting with token in URL
// response.sendRedirect("/callback?token=" + jwtToken);
// Token appears in server logs, referrer headers, CDN logs

// ✅ GOOD — set as httpOnly cookie, never expose in URL
Cookie cookie = new Cookie("accessToken", jwtToken);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath("/");
response.addCookie(cookie);
response.sendRedirect("/callback");
# ❌ BAD — redirecting with token in URL
# return RedirectResponse(url=f"/callback?token={jwt_token}")
# Token appears in server logs, referrer headers, CDN logs

# ✅ GOOD — set as httpOnly cookie, never expose in URL
from fastapi.responses import RedirectResponse

response = RedirectResponse(url="/callback")
response.set_cookie(
    key="accessToken",
    value=jwt_token,
    httponly=True,
    secure=True,
    samesite="strict",
)
// ❌ BAD — redirecting with token in URL
// return Redirect($"/callback?token={jwtToken}");
// Token appears in server logs, referrer headers, CDN logs

// ✅ GOOD — set as httpOnly cookie, never expose in URL
Response.Cookies.Append("accessToken", jwtToken, new CookieOptions
{
    HttpOnly = true,
    Secure   = true,
    SameSite = SameSiteMode.Strict,
    Path     = "/",
});
return Redirect("/callback");

ส่วนที่ 9: JWT เทียบกับ Session Cookies

คุณสมบัติJWTSession Cookie
Stateless✅ ใช่❌ ไม่ (ต้องจัดเก็บ)
Revocation⚠️ ยาก✅ ง่าย (ลบจากฐานข้อมูล)
Scalability✅ เยี่ยม (ไม่มีสถานะเซิร์ฟเวอร์)⚠️ ต้อง shared session store
CSRF Protection✅ ง่ายกว่า (ไม่มี cookie)❌ ต้อง CSRF token
Performance✅ เร็ว (ไม่ต้องค้นหาฐานข้อมูล)⚠️ ต้องค้นหา session
Across Domains✅ ทำงาน (CORS)❌ บล็อกโดย SameSite
Mobile Apps✅ Native❌ รองรับจำกัด
Refresh Pattern✅ Built-in (refresh tokens)❌ Implicit
Data Size⚠️ เพิ่มขึ้นตามคำ claim✅ น้อยที่สุด (เฉพาะ ID)

ใช้ JWT เมื่อ:

  • สร้าง APIs ที่ใช้โดยไคลเอนต์หลายตัว
  • แอปมือถือกับไคลเอนต์ native
  • สถาปัตยกรรม microservices
  • Single Sign-On (SSO)

ใช้ Session Cookies เมื่อ:

  • แอปเว็บแบบ server-rendered แบบดั้งเดิม
  • สถาปัตยกรรม monolithic ที่เรียบง่าย
  • ความสามารถในการเพิกถอนที่ง่ายเป็นสิ่งสำคัญ
  • ต้องความซับซ้อนต่ำสุด

ส่วนที่ 10: Checklist สำหรับการผลิต

ก่อนปล่อย JWT authentication ไปยังการผลิต:

ความปลอดภัย

  • ใช้ RS256 หรือ ES256 (อสมมาตร) สำหรับระบบแบบกระจาย
  • ถ้าใช้ HS256 ให้แน่ใจว่าลับมีอย่างน้อย 32 ไบต์ของข้อมูลสุ่ม
  • เก็บลับในตัวแปรสภาพแวดล้อม ไม่ใช่ในโค้ด
  • ใช้ httpOnly, Secure, SameSite cookies
  • ตรวจสอบและ whitelist อัลกอริทึมใน jwt.verify()
  • ตั้งค่า short expiration สำหรับ access token (15 นาที)
  • ใช้ refresh token rotation
  • อย่าเก็บข้อมูลส่วนบุคคล, รหัสผ่าน หรือข้อมูลทางการเงินใน payload
  • ใช้การเพิกถอน token (Redis blacklist หรือ versioning)
  • HTTPS เท่านั้น — ไม่ส่ง JWT บน HTTP
  • ตรวจสอบ iss (issuer) และ aud (audience) claims

การใช้งาน

  • จัดการข้อผิดพลาดการหมดอายุของ token อย่างประณีต
  • ใช้งาน automatic token refresh ฝั่งไคลเอนต์
  • ทดสอบ middleware กับ expired/invalid/malformed tokens
  • บันทึกความล้มเหลวของการยืนยันตัวตน (ระวังความพยายามซ้ำ)
  • ติดตามการทำซ้ำของการรีเฟรช token (สัญญาณของการขโมย refresh token)
  • ใช้ rate limiting บน /auth/login และ /auth/refresh
  • เพิ่มการตรวจสอบ CORS ถ้าให้บริการคำขอ cross-origin

การดำเนินการ

  • เอกสารเกี่ยวกับขั้นตอนการหมุนเวียนลับ
  • ติดตามความจำ Redis หากใช้กลยุทธ์ blacklist
  • ตั้งค่าการแจ้งเตือนสำหรับอัตราการรีเฟรช token ที่ผิดปกติ
  • วางแผนการหมุนเวียนคีย์ (ขั้นต่ำรายปีสำหรับคีย์ RSA)
  • ทดสอบการออกจากระบบทั่วทุกไคลเอนต์
  • ตรวจสอบว่า token ถูกปล่อย หลังจากเปลี่ยนรหัสผ่าน
  • ทดสอบใน staging ด้วย production TTLs

การทดสอบ

// Test: Expired token is rejected
test('rejects expired token', () => {
  const token = jwt.sign(payload, secret, { expiresIn: '0s' });
  sleep(1000);
  expect(() => jwt.verify(token, secret)).toThrow('expired');
});

// Test: Token with wrong algorithm is rejected
test('rejects token signed with wrong algorithm', () => {
  const token = jwt.sign(payload, secret, { algorithm: 'HS256' });
  expect(() => jwt.verify(token, secret, { algorithms: ['RS256'] }))
    .toThrow('invalid');
});

// Test: Invalid signature is rejected
test('rejects token with invalid signature', () => {
  const token = jwt.sign(payload, secret) + 'tampered';
  expect(() => jwt.verify(token, secret)).toThrow('invalid');
});

// Test: Refresh token rotation works
test('revokes old refresh token after rotation', async () => {
  const oldToken = generateRefreshToken(userId);
  const newTokens = await refreshTokens({ refreshToken: oldToken });
  
  // Old token should be blacklisted
  expect(await isTokenRevoked(decodeToken(oldToken)!.jti!)).toBe(true);
  // New refresh token should work
  expect(() => jwt.verify(newTokens.refreshToken, PUBLIC_KEY)).not.toThrow();
});
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class JwtTokenServiceTest {

    @Autowired JwtTokenService jwtService;
    @Autowired TokenRevocationService revocationService;

    @Test
    void rejectsExpiredToken() throws InterruptedException {
        // Create a token that expires in 1ms
        String token = Jwts.builder()
            .subject("user1")
            .expiration(new Date(System.currentTimeMillis() + 1))
            .signWith(jwtService.getPrivateKey(), Jwts.SIG.RS256)
            .compact();

        Thread.sleep(10);
        assertThrows(RuntimeException.class, () -> jwtService.verifyToken(token));
    }

    @Test
    void rejectsWrongAlgorithm() {
        // Sign with HS256 but service expects RS256
        SecretKey hmacKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
        String token = Jwts.builder().subject("user1").signWith(hmacKey).compact();
        assertThrows(RuntimeException.class, () -> jwtService.verifyToken(token));
    }

    @Test
    void rejectsTamperedSignature() {
        String token = jwtService.generateAccessToken("user1", "test@test.com", "user");
        assertThrows(RuntimeException.class,
            () -> jwtService.verifyToken(token + "tampered"));
    }

    @Test
    void refreshTokenRotationRevokesOldToken() throws Exception {
        String oldToken = jwtService.generateRefreshToken("user1");
        Claims oldClaims = jwtService.verifyToken(oldToken);

        // Perform rotation
        tokenRefreshService.refreshTokens(oldToken);

        // Old token JTI should be revoked
        assertTrue(revocationService.isRevoked(oldClaims.getId()));
    }
}
import pytest
import time

def test_rejects_expired_token():
    from datetime import timedelta, timezone
    payload = {
        "sub": "user1",
        "exp": datetime.now(timezone.utc) - timedelta(seconds=1),
    }
    token = jwt.encode(payload, PRIVATE_KEY_PEM, algorithm="RS256")
    with pytest.raises(ValueError, match="Token expired"):
        verify_token(token)

def test_rejects_wrong_algorithm():
    import secrets
    hmac_secret = secrets.token_hex(32)
    token = jwt.encode({"sub": "user1"}, hmac_secret, algorithm="HS256")
    with pytest.raises(ValueError, match="Invalid token"):
        verify_token(token)

def test_rejects_tampered_signature():
    token = generate_access_token("user1", "test@test.com", "user")
    with pytest.raises(ValueError, match="Invalid token"):
        verify_token(token + "tampered")

@pytest.mark.asyncio
async def test_refresh_rotation_revokes_old_token():
    old_token = generate_refresh_token("user1")
    old_jti   = jwt.decode(old_token, options={"verify_signature": False})["jti"]

    await refresh_tokens(RefreshTokenRequest(refresh_token=old_token))

    assert await is_token_revoked(old_jti)
using Xunit;
using System.IdentityModel.Tokens.Jwt;

public class JwtTokenServiceTests
{
    private readonly JwtTokenService        _jwtService;
    private readonly TokenRevocationService _revocation;
    private readonly TokenRefreshController _refreshController;

    public JwtTokenServiceTests()
    {
        _jwtService = new JwtTokenService();
        // Initialize other services...
    }

    [Fact]
    public void RejectsExpiredToken()
    {
        var handler = new JwtSecurityTokenHandler();
        var descriptor = new SecurityTokenDescriptor
        {
            Subject  = new ClaimsIdentity(new[] { new Claim("sub", "user1") }),
            Expires  = DateTime.UtcNow.AddSeconds(-1), // Already expired
            SigningCredentials = new SigningCredentials(
                _jwtService.PrivateKey, SecurityAlgorithms.RsaSha256),
        };
        var token = handler.WriteToken(handler.CreateToken(descriptor));

        Assert.Throws<Exception>(() => _jwtService.VerifyToken(token));
    }

    [Fact]
    public void RejectsTamperedSignature()
    {
        var token = _jwtService.GenerateAccessToken("user1", "test@test.com", "user");
        Assert.Throws<Exception>(() => _jwtService.VerifyToken(token + "tampered"));
    }

    [Fact]
    public async Task RefreshRotationRevokesOldToken()
    {
        var oldToken  = _jwtService.GenerateRefreshToken("user1");
        var principal = _jwtService.VerifyToken(oldToken);
        var oldJti    = principal.FindFirstValue(JwtRegisteredClaimNames.Jti)!;

        await _refreshController.RefreshTokens(
            new RefreshTokenRequest { RefreshToken = oldToken });

        Assert.True(await _revocation.IsRevokedAsync(oldJti));
    }
}

บทสรุป

JWT authentication เมื่อใช้งานอย่างถูกต้อง ให้รากฐานที่ปลอดภัยและปรับขนาดได้สำหรับ APIs สมัยใหม่ กุญแจสำคัญคือการทำความเข้าใจการแลกเปลี่ยน: ความเป็น stateless ลดความยุ่งยากในการกระจาย แต่ซับซ้อนการเพิกถอน Access token ที่อยู่อาศัยสั้นพร้อม refresh rotation จะบันทึกความสมดุลที่ถูกต้องสำหรับแอปพลิเคชันส่วนใหญ่

จำไว้: JWT ถูก ตรวจสอบ ไม่ใช่ เข้ารหัส ใครก็ได้สามารถอ่าน payload ได้ สิ่งสำคัญคือการปฏิบัติต่อ token เป็นข้อมูลสาธารณะและเก็บข้อมูลที่ไวต่อข้อมูลบนเซิร์ฟเวอร์

เริ่มต้นด้วย production checklist ทดสอบกรณีขอบเขตอย่างถี่ถ้วน และพิจารณาเพิ่มการตรวจสอบเพื่อติดตามแพตเทิร์นการรีเฟรช token — ความเพิ่มขึ้นที่น่าสงสัยอาจบ่งชี้ถึง refresh token ที่ถูกบุกรุก

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

บทความที่เกี่ยวข้อง