JWT: Richtige Verwendung und häufige Sicherheitsfehler
1. Übersicht
JWT (JSON Web Token) wird überall zur API-Authentifizierung verwendet, aber viele Systeme implementieren es fehlerhaft und schaffen so erhebliche Sicherheitslücken. Dieser Artikel fasst zusammen, wie man JWTs in Spring Boot korrekt erstellt und validiert, zeigt klassische Sicherheitsfehler (alg confusion, schwache Secrets, fehlende Expiration-Überprüfung…) und wie man sie vermeidet. Nach dem Lesen wirst du wissen, wie du JWT sicher konfigurierst und den JWT-Code von Kollegen selbstbewusst überprüfen kannst.
2. JWT-Struktur und Funktionsweise
JWT besteht aus 3 Teilen: header.payload.signature, base64url-codiert, durch Punkte getrennt.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.4Z... (signature)
- Header: enthält
alg(Signaturalgorithmus) undtyp. - Payload: enthält Claims wie
sub,exp,iat,roles. - Signature: Signatur zur Verifizierung, dass das Token nicht manipuliert wurde.
Hinweis: JWT verschlüsselt nicht die Payload, sondern kodiert sie nur. Der Inhalt ist für jeden lesbar, der das Token hat. Gib kein Passwort oder sensitive PII in den Claims ein.
3. Abhängigkeit hinzufügen
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
Die Bibliothek jjwt (io.jsonwebtoken) ist die beliebteste für Java, hat eine klare API und blockiert standardmäßig einige häufige Fehler.
4. JWTs korrekt erstellen und verifizieren
4.1. Einen starken Secret-Schlüssel generieren
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
public class JwtKeyFactory {
public static SecretKey generateKey() {
return Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
}
}
Keys.secretKeyFor generiert einen zufälligen Schlüssel mit ausreichender Länge für HS256 (≥ 256 Bit). In der Produktion speichert man diesen Schlüssel in einem Secret Manager (Vault, AWS Secrets Manager), nicht im Code.
4.2. Token mit TokenService erstellen
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;
public class TokenService {
private final SecretKey key;
public TokenService(SecretKey key) {
this.key = key;
}
public String generateToken(String username) {
Instant now = Instant.now();
return Jwts.builder()
.subject(username)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(900))) // 15 Minuten
.signWith(key)
.compact();
}
}
expirationauf 15 Minuten setzen — Access Tokens sollten kurzlebig sein.signWith(key)wählt automatisch einen passenden Algorithmus basierend auf dem Schlüsseltyp, verhindert, dass der Clientalgbeeinflussen kann.
4.3. Token verifizieren
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
public class TokenVerifier {
private final SecretKey key;
public TokenVerifier(SecretKey key) {
this.key = key;
}
public Claims verify(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (JwtException e) {
throw new InvalidTokenException("Token ungültig oder abgelaufen", e);
}
}
}
parseSignedClaims überprüft automatisch die Signatur und exp. Bei ungültiger Signatur oder abgelaufenem Token wirft es JwtException — du kannst das abfangen und in eine Domain-Exception konvertieren.
5. Sicherheitsfehler #1: alg=none oder alg confusion
Ein Angreifer ändert den Header zu {"alg":"none"} und sendet ein Token ohne Signatur. Wenn das Backend keinen Algorithmus erzwingt, könnte es gefälschte Tokens akzeptieren.
// FALSCH: Algorithm nicht angegeben
Jwts.parser().build().parseSignedClaims(token); // alte Bibliotheken sind anfällig
Abhilfe — immer einen Algorithmus auf die Whitelist setzen:
import io.jsonwebtoken.Jwts;
Jwts.parser()
.verifyWith(key) // akzeptiere nur Token mit bekanntem Schlüssel
.build()
.parseSignedClaims(token); // jjwt 0.12+ blockiert alg=none automatisch
Hinweis: Bei anderen JWT-Bibliotheken (jose4j, nimbus-jose-jwt) musst du explizit eine Whitelist für Algorithmen konfigurieren (z. B. nur
HS256erlauben). Vertrau niemals demalg-Feld im vom Client gesendeten Token.
Eine noch gefährlichere Variante: alg confusion zwischen RS256 und HS256. Wenn der Server mit einem öffentlichen RSA-Schlüssel verifiziert, aber der Code diesen dann als Geheimnis für HMAC verwendet, wenn alg=HS256, kann ein Angreifer das Token mit dem öffentlichen Schlüssel selbst signieren (der ja öffentlich ist). Lösung: den Algorithmus fest für den Use-Case definieren, nicht den Code den alg aus dem Token selbst auswählen lassen und entsprechend verifizieren.
6. Sicherheitsfehler #2: schwache oder exponierte Secrets
// FALSCH: kurzes Secret, leicht zu brute-forcen
String secret = "mysecret123";
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
HS256 erfordert ein Schlüssel ≥ 256 Bit (32 Byte). Kurze Secrets können mit Tools wie hashcat offline in wenigen Stunden gebrochen werden.
// KORREKT: genügend langes, zufälliges Secret
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
Darüber hinaus:
- Committe das Secret nicht in Git. Verwende Umgebungsvariablen oder einen Secret Manager.
- Rotiere das Secret regelmäßig, besonders nachdem Personal mit Zugriff auf das Secret das Unternehmen verlassen hat.
# application.properties - von env lesen, nicht hardcodieren
jwt.secret=${JWT_SECRET}
7. Sicherheitsfehler #3: fehlende Expiration-Überprüfung oder zu lange Token-Lebensdauer
// FALSCH: Access Token lebt 30 Tage
.expiration(Date.from(now.plus(30, ChronoUnit.DAYS)))
Wenn Access Tokens zu lange leben, bedeutet das, wenn sie exponiert werden, hat der Angreifer langfristigen Zugriff, den du nicht widerrufen kannst (JWT ist zustandslos, hat standardmäßig keinen Logout-Mechanismus).
Richtiges Muster: kurze Access Tokens (5–15 Minuten) + längere Refresh Tokens, die auf dem Server gespeichert (oder rotiert) werden, um sie widerrufen zu können.
public class TokenPair {
private final String accessToken; // lebt 15 Minuten
private final String refreshToken; // lebt 7 Tage, in DB gespeichert um widerrufen zu werden
// constructor, getters
}
public class RefreshTokenRepository {
public boolean isRevoked(String refreshTokenId) {
// DB/Redis abfragen, wenn Token abgemeldet wurde, true zurückgeben
return false;
}
}
Wenn ein Refresh Token widerrufen wird, lebt das alte Access Token noch bis zu seinem Ablauf — deshalb muss das Access Token kurz sein.
8. Sicherheitsfehler #4: sensitive Daten in die Payload packen
// FALSCH: Payload ist öffentlich, jeder mit dem Token kann es lesen
.claim("password", user.getPassword())
.claim("creditCard", user.getCardNumber())
Die Payload ist nur base64-codiert, nicht verschlüsselt. Mit jwt.io kann man sie dekodieren und alles sehen. Man sollte nur Claims eintragen, die für die Autorisierung nötig sind: sub, roles, exp, scope.
// KORREKT
.subject(user.getUsername())
.claim("roles", user.getRoles())
9. Sicherheitsfehler #5: fehlende Issuer/Audience-Validierung bei gemeinsamen Secrets zwischen Services
Wenn mehrere Services das gleiche Secret zum Signieren verwenden, kann ein für Service A ausgestelltes Token auf Service B wiedergespielt werden, wenn aud (Audience) nicht überprüft wird.
import io.jsonwebtoken.Jwts;
Claims claims = Jwts.parser()
.verifyWith(key)
.requireAudience("order-service") // akzeptiere nur Tokens für diesen Service
.requireIssuer("auth-server")
.build()
.parseSignedClaims(token)
.getPayload();
requireAudience und requireIssuer zwingen den Parser, eine Exception zu werfen, wenn die Claims nicht übereinstimmen — das stoppt die Nutzung von Tokens für den falschen Zweck.
10. Integration mit Spring Security Filter
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
public class JwtAuthFilter extends OncePerRequestFilter {
private final TokenVerifier verifier;
public JwtAuthFilter(TokenVerifier verifier) {
this.verifier = verifier;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
try {
var claims = verifier.verify(header.substring(7));
var auth = new UsernamePasswordAuthenticationToken(
claims.getSubject(), null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (InvalidTokenException e) {
SecurityContextHolder.clearContext();
}
}
chain.doFilter(request, response);
}
}
- Hol das Token aus dem Header
Authorization: Bearer <token>. - Verifiziere mit dem
TokenVerifieraus Abschnitt 4.3. - Bei ungültigem Token Context löschen — die Anfrage wird durch Spring Security im nächsten Autorisierungsschritt blockiert, keine Exception wird direkt im Filter geworfen.
Hinweis: Speichere das JWT nicht in
localStoragebei einer öffentlichen SPA — das ist anfällig für XSS-Diebstahl. Bevorzuge das Speichern inHttpOnly-,Secure-,SameSite=Strict-Cookies, wenn möglich.
11. Verhalten beim Token-Verify testen
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import javax.crypto.SecretKey;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
class TokenVerifierTest {
private final SecretKey key = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
private final TokenService tokenService = new TokenService(key);
private final TokenVerifier verifier = new TokenVerifier(key);
@Test
void shouldVerifyValidToken() {
String token = tokenService.generateToken("alice");
var claims = verifier.verify(token);
assertThat(claims.getSubject()).isEqualTo("alice");
}
@Test
void shouldRejectTokenSignedWithDifferentKey() {
SecretKey otherKey = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
TokenService fakeService = new TokenService(otherKey);
String forgedToken = fakeService.generateToken("attacker");
assertThatThrownBy(() -> verifier.verify(forgedToken))
.isInstanceOf(InvalidTokenException.class);
}
}
Der zweite Test simuliert genau die Situation, in der ein Angreifer sein eigenes Token mit einem anderen Schlüssel signiert — der Verifier muss es ablehnen, nicht stillschweigend akzeptieren.
shouldVerifyValidToken() PASS
shouldRejectTokenSignedWithDifferentKey() PASS
12. Schnelle Checkliste vor dem Deployment
- Secret ≥ 256 Bit, in Secret Manager gespeichert, nicht hardcodiert.
- Algorithmus auf Whitelist, vertrau dem
algvom Token nicht. - Access Token kurzlebig (≤ 15 Minuten), Refresh Token zum Widerrufen vorhanden.
- Keine sensiblen Daten in der Payload.
-
exp,iss,audvalidieren, wenn nötig. - Token in
HttpOnly-Cookie stattlocalStoragespeichern, wenn Frontend eine SPA ist. - Revoke/Blacklist-Mechanismus für Refresh Tokens vorhanden.
13. Fazit
JWT selbst hat keine Fehler — die meisten Probleme entstehen durch falsche Implementierung: schwache Secrets, Vertrauen in alg vom Client, zu lange Token-Lebensdauer oder sensitive Daten in der öffentlichen Payload. Wenn du die Checkliste aus Abschnitt 12 anwendest und immer getestete JWT-Bibliotheken (jjwt, nimbus-jose-jwt) statt eigenem Parser verwendest, vermeidest du die meisten häufigen Lücken. Der nächste Schritt: OAuth 2.0 / OpenID Connect für bessere Token-Lebenszyklus-Verwaltung über mehrere Services hinweg.