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 นั้นใช้ได้จนกว่า
expclaim จะหมดอายุ 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
ส่วนแรกคือ 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): ใครสร้าง tokensub(subject): ใครที่ token นี้อ้างอิงaud(audience): ใครที่ควรยอมรับ tokenexp(expiration time): Unix timestamp — token ไม่ถูกต้องหลังจากเวลานี้iat(issued at): Unix timestamp — เมื่อสร้าง tokennbf(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 |
ที่แนะนำ: httpOnly Cookie + CSRF Protection
/**
* 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 payloadusing 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
| คุณสมบัติ | JWT | Session 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 ที่ถูกบุกรุก