JWT Token Oluşturma ve Doğrulama
Giriş — Neden Bu Konu Önemli?
Önceki derste JWT'nin yapısını ve prensiplerini öğrendik — Header, Payload, Signature'dan oluşan üç parçalı yapıyı, Base64URL encoding'i ve claim'leri. Şimdi teoriden pratiğe geçiyoruz: Spring Boot'ta JWT token oluşturma, doğrulama, claim okuma ve hata yönetimini JJWT kütüphanesi ile adım adım uygulayacağız.
Bu derste yazacağımız JwtService sınıfı, tüm JWT auth sisteminin kalbi olacak. Token oluşturma, doğrulama ve claim çıkarma işlemlerinin hepsini bu servis üstlenecek. Authentication filter (bir sonraki ders) bu servisi kullanarak gelen istekleri doğrulayacak.
Gerçek Hayat Analojisi: JWT oluşturma, bir konser bileti basmak gibidir. Bilet üzerinde kimin için olduğu (subject), hangi etkinlik olduğu (issuer), geçerlilik tarihi (expiration), koltuk numarası (custom claims) yazar. Girişteki görevli bileti kontrol ederken hologramı inceler (signature verification), tarihe bakar (expiration check), ve ismi kimlikle eşleştirir (subject validation). JJWT kütüphanesi, bu bilet basma ve kontrol etme sürecinin Java implementasyonudur.
JJWT Kütüphanesi ve Dependency
Spring Boot'ta JWT işlemleri için en popüler kütüphane JJWT (Java JWT)'dir. Modüler yapıdadır — API, implementasyon ve JSON serializer ayrı artifact'lerdedir:
<!-- API — derleme zamanında kullanılır -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<!-- Implementasyon — runtime'da kullanılır -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<!-- Jackson JSON serializer — runtime'da kullanılır -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>💡 Neden modüler?
jjwt-apiderleme zamanında API'yi sunar,jjwt-implruntime'da implementasyonu sağlar. Bu sayede kodunuz API'ye bağımlı olur, implementasyona değil — test ve mock kolaylaşır.jjwt-jacksonise claim'lerin JSON serialization'ını Jackson ile yapar (Spring Boot projesinde zaten Jackson vardır).
Konfigürasyon
JWT ile ilgili ayarları application.yml'de tutun — kod içine hardcode etmeyin:
# application.yml
jwt:
secret: "dGhpcyBpcyBhIHZlcnkgc2VjcmV0IGtleSBmb3Igand0IGF1dGhlbnRpY2F0aW9u" # Base64 encoded
access-token-expiration: 900000 # 15 dakika (milisaniye)
refresh-token-expiration: 604800000 # 7 gün (milisaniye)
issuer: "myapp-api"⚠️ Üretim ortamında secret'ı YAML'a yazmayın! Environment variable veya vault'tan okuyun:
>
``
yaml jwt: secret: "${JWT_SECRET}" # Environment variable'dan``
>
``
bash export JWT_SECRET="dGhpcyBpcyBhIHZlcnkgc2VjcmV0IGtleSBmb3Igand0"``
JwtProperties — Type-Safe Configuration
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secret;
private long accessTokenExpiration = 900_000; // 15 dk default
private long refreshTokenExpiration = 604_800_000; // 7 gün default
private String issuer = "myapp-api";
// Getter ve Setter'lar
public String getSecret() { return secret; }
public void setSecret(String secret) { this.secret = secret; }
public long getAccessTokenExpiration() { return accessTokenExpiration; }
public void setAccessTokenExpiration(long ms) { this.accessTokenExpiration = ms; }
public long getRefreshTokenExpiration() { return refreshTokenExpiration; }
public void setRefreshTokenExpiration(long ms) { this.refreshTokenExpiration = ms; }
public String getIssuer() { return issuer; }
public void setIssuer(String issuer) { this.issuer = issuer; }
}@ConfigurationProperties ile YAML'daki değerler type-safe olarak Java'ya bağlanır. @Value annotation'ı da kullanılabilir ama @ConfigurationProperties daha temiz ve test edilebilirdir.
Secret Key Yönetimi
Secret key'in güvenliği tüm JWT sisteminin güvenliğidir. Key zayıfsa, saldırgan kendi token'ını oluşturabilir.
// ❌ YANLIŞ: Kısa veya tahmin edilebilir key
String secret = "mySecret"; // Brute-force ile saniyeler içinde kırılır!
String secret = "password123"; // Dictionary attack ile anında kırılır!
// ✅ DOĞRU: En az 256-bit (32 byte) rastgele, Base64 encoded key
// Terminal'de üretme:
// openssl rand -base64 32
String secret = "dGhpcyBpcyBhIHZlcnkgc2VjcmV0IGtleSBmb3Igand0";
// SecretKey nesnesi oluşturma
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));Hangi Algoritma, Kaç Bit Key?
| Algoritma | Minimum Key Boyutu | Güvenlik Seviyesi |
|---|---|---|
| HS256 (HMAC-SHA256) | 256-bit (32 byte) | Yeterli |
| HS384 (HMAC-SHA384) | 384-bit (48 byte) | Yüksek |
| HS512 (HMAC-SHA512) | 512-bit (64 byte) | En yüksek |
// Programmatik key üretme (test/geliştirme için)
SecretKey generatedKey = Jwts.SIG.HS256.key().build();
String encodedKey = Encoders.BASE64.encode(generatedKey.getEncoded());
System.out.println("Generated key: " + encodedKey);
// Bu key'i application.yml'e kopyalayınJwtService — Tam Implementasyon
@Service
public class JwtService {
private final JwtProperties jwtProperties;
private final SecretKey signingKey;
public JwtService(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
// Key'i bir kez oluştur ve tekrar kullan — performans için
this.signingKey = Keys.hmacShaKeyFor(
Decoders.BASE64.decode(jwtProperties.getSecret())
);
}
// ══════════════════════════════════════
// Token Oluşturma
// ══════════════════════════════════════
public String generateAccessToken(UserDetails userDetails) {
Map<String, Object> extraClaims = new HashMap<>();
extraClaims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList());
extraClaims.put("type", "access");
return buildToken(extraClaims, userDetails.getUsername(),
jwtProperties.getAccessTokenExpiration());
}
public String generateRefreshToken(UserDetails userDetails) {
Map<String, Object> extraClaims = new HashMap<>();
extraClaims.put("type", "refresh");
// Refresh token'da roller yok — sadece yenileme amaçlı
return buildToken(extraClaims, userDetails.getUsername(),
jwtProperties.getRefreshTokenExpiration());
}
private String buildToken(Map<String, Object> claims, String subject, long expirationMs) {
Instant now = Instant.now();
return Jwts.builder()
.claims(claims) // Custom claims
.subject(subject) // sub: username
.issuer(jwtProperties.getIssuer()) // iss: uygulama adı
.issuedAt(Date.from(now)) // iat: oluşturulma zamanı
.expiration(Date.from(now.plusMillis(expirationMs))) // exp: bitiş
.signWith(signingKey) // İmzalama (HS256 otomatik)
.compact(); // String'e dönüştür
}
// ══════════════════════════════════════
// Token Doğrulama
// ══════════════════════════════════════
public boolean isTokenValid(String token, UserDetails userDetails) {
try {
String username = extractUsername(token);
return username.equals(userDetails.getUsername())
&& !isTokenExpired(token);
} catch (JwtException e) {
return false; // Malformed, expired, invalid signature
}
}
public boolean isAccessToken(String token) {
return "access".equals(extractTokenType(token));
}
public boolean isRefreshToken(String token) {
return "refresh".equals(extractTokenType(token));
}
// ══════════════════════════════════════
// Claim Çıkarma
// ══════════════════════════════════════
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
@SuppressWarnings("unchecked")
public List<String> extractRoles(String token) {
return extractClaim(token, claims -> claims.get("roles", List.class));
}
public String extractTokenType(String token) {
return extractClaim(token, claims -> claims.get("type", String.class));
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
// ══════════════════════════════════════
// Private Yardımcılar
// ══════════════════════════════════════
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(signingKey) // Signature doğrulama
.requireIssuer(jwtProperties.getIssuer()) // Issuer kontrolü
.build()
.parseSignedClaims(token) // Parse + doğrulama
.getPayload(); // Claims döner
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
}buildToken Metodu — Adım Adım
Jwts.builder()
│
├─ .claims(claims) → Custom alanlar: roles, type
├─ .subject("tolgahan") → sub: Kim için bu token?
├─ .issuer("myapp-api") → iss: Kim oluşturdu?
├─ .issuedAt(now) → iat: Ne zaman oluşturuldu?
├─ .expiration(now+15min) → exp: Ne zaman sona erecek?
├─ .signWith(signingKey) → İmzala (HS256 + secret key)
└─ .compact() → "eyJhbGciOiJIUzI1NiJ9.eyJ..." → StringHata Senaryoları ve Exception Handling
JJWT, farklı hata durumları için farklı exception'lar fırlatır. Her birini ayrı yakalamak, istemciye anlamlı hata mesajları dönmenizi sağlar:
public Claims validateAndExtract(String token) {
try {
return Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
// Token süresi dolmuş — "exp" claim geçmiş
log.warn("Token expired for user: {}", e.getClaims().getSubject());
throw new TokenExpiredException("Token süresi dolmuş");
} catch (MalformedJwtException e) {
// Token formatı bozuk — Base64 decode veya JSON parse hatası
log.error("Malformed JWT: {}", e.getMessage());
throw new InvalidTokenException("Geçersiz token formatı");
} catch (SecurityException | SignatureException e) {
// Signature doğrulama başarısız — token manipüle edilmiş!
log.error("JWT signature verification failed");
throw new InvalidTokenException("Token imzası geçersiz");
} catch (UnsupportedJwtException e) {
// Desteklenmeyen JWT türü (örn: JWE beklenirken JWS geldi)
log.error("Unsupported JWT: {}", e.getMessage());
throw new InvalidTokenException("Desteklenmeyen token türü");
} catch (IllegalArgumentException e) {
// Token string null veya boş
log.error("JWT token is null or empty");
throw new InvalidTokenException("Token bulunamadı");
}
}Exception Hiyerarşisi
JwtException (JJWT base exception)
├── MalformedJwtException → Token yapısı bozuk
├── ExpiredJwtException → Token süresi dolmuş
├── UnsupportedJwtException → Desteklenmeyen JWT türü
├── SignatureException → İmza doğrulanamadı
└── MissingClaimException → Zorunlu claim eksik💡 ExpiredJwtException özel durumu: Bu exception,
getClaims()metodu ile expired token'ın claim'lerine erişim sağlar. Bu, refresh token flow'unda kullanıcıyı tanımlamak için kullanışlıdır.
Custom Claims Ekleme
İş mantığına özel bilgiler token'a eklenebilir:
public String generateTokenWithCustomClaims(CustomUserDetails user) {
return Jwts.builder()
.subject(user.getUsername())
.claim("userId", user.getId()) // Kullanıcı ID
.claim("email", user.getEmail()) // E-posta
.claim("roles", user.getRoles()) // Roller
.claim("department", user.getDepartment()) // Departman
.claim("tenantId", user.getTenantId()) // Multi-tenant uygulamalar için
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 900_000))
.signWith(signingKey)
.compact();
}
// Custom claim'leri okuma
public Long extractUserId(String token) {
return extractClaim(token, claims -> claims.get("userId", Long.class));
}
public String extractEmail(String token) {
return extractClaim(token, claims -> claims.get("email", String.class));
}⚠️ Token boyutuna dikkat: Her custom claim token boyutunu artırır. JWT her HTTP isteğinde gönderilir — büyük token'lar bant genişliğini tüketir. Token'a sadece sıklıkla ihtiyaç duyulan bilgileri ekleyin. Nadir erişilen bilgileri veritabanından çekmek daha iyidir.
Token Oluşturma Testleri
@SpringBootTest
class JwtServiceTest {
@Autowired
private JwtService jwtService;
private UserDetails testUser;
@BeforeEach
void setup() {
testUser = User.builder()
.username("tolgahan")
.password("encoded-pass")
.authorities("ROLE_USER", "ROLE_ADMIN")
.build();
}
@Test
void shouldGenerateValidAccessToken() {
String token = jwtService.generateAccessToken(testUser);
assertNotNull(token);
assertEquals(3, token.split("\\.").length); // 3 parçalı (header.payload.signature)
assertEquals("tolgahan", jwtService.extractUsername(token));
assertTrue(jwtService.isTokenValid(token, testUser));
assertTrue(jwtService.isAccessToken(token));
assertFalse(jwtService.isRefreshToken(token));
}
@Test
void shouldExtractRoles() {
String token = jwtService.generateAccessToken(testUser);
List<String> roles = jwtService.extractRoles(token);
assertNotNull(roles);
assertTrue(roles.contains("ROLE_USER"));
assertTrue(roles.contains("ROLE_ADMIN"));
}
@Test
void shouldGenerateRefreshToken() {
String token = jwtService.generateRefreshToken(testUser);
assertTrue(jwtService.isRefreshToken(token));
assertFalse(jwtService.isAccessToken(token));
assertEquals("tolgahan", jwtService.extractUsername(token));
}
@Test
void shouldRejectTamperedToken() {
String token = jwtService.generateAccessToken(testUser);
String tampered = token.substring(0, token.length() - 1) + "x";
assertFalse(jwtService.isTokenValid(tampered, testUser));
}
@Test
void shouldRejectExpiredToken() throws InterruptedException {
// Test ortamında kısa expiration ile test
// (üretimde bu test @TestPropertySource ile yapılır)
// Thread.sleep ile bekleme yerine, mock clock kullanılabilir
}
@Test
void shouldRejectTokenForDifferentUser() {
String token = jwtService.generateAccessToken(testUser);
UserDetails differentUser = User.builder()
.username("baskabiri")
.password("pass")
.authorities("ROLE_USER")
.build();
assertFalse(jwtService.isTokenValid(token, differentUser));
}
}AuthenticationService — Login ve Token Verme
JwtService token oluşturur ama login flow'unu yönetmez. AuthenticationService kullanıcı kimlik doğrulamasını yapıp token verir:
@Service
public class AuthenticationService {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public AuthenticationService(AuthenticationManager authenticationManager,
JwtService jwtService,
UserDetailsService userDetailsService) {
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
public AuthResponse login(LoginRequest request) {
// 1. Kullanıcı adı ve şifre doğrulama
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(), request.password())
);
// → Yanlış şifre ise BadCredentialsException fırlatılır
// 2. UserDetails'i yükle
UserDetails userDetails = userDetailsService
.loadUserByUsername(request.username());
// 3. Token'ları oluştur
String accessToken = jwtService.generateAccessToken(userDetails);
String refreshToken = jwtService.generateRefreshToken(userDetails);
return new AuthResponse(accessToken, refreshToken);
}
}
public record LoginRequest(String username, String password) {}
public record AuthResponse(String accessToken, String refreshToken) {}Auth Controller
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationService authService;
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
AuthResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
}Özet
JJWT kütüphanesi modüler yapıdadır:
jjwt-api(compile),jjwt-impl+jjwt-jackson(runtime)Secret key en az 256-bit (32 byte), Base64 encoded olmalı —
openssl rand -base64 32ile üretinEnvironment variable kullanın — secret'ı YAML'a yazmayın (
${JWT_SECRET})JwtService tek sorumluluk: token oluşturma, doğrulama, claim çıkarma — login flow'u burada değil
Token tipini claim olarak ekleyin (
"type": "access"vs"type": "refresh") — access/refresh karışmasını önlerHer exception'ı ayrı ayrı yakalayın — ExpiredJwtException, MalformedJwtException, SignatureException farklı anlamlar taşır
Token'a sadece sık ihtiyaç duyulan bilgileri ekleyin — büyük token'lar her istekte bant genişliği tüketir
SigningKey'i bir kez oluşturup reuse edin — her istekte Base64 decode yapmak gereksiz CPU tüketir
AI Asistan
Sorularını yanıtlamaya hazır