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) und typ.
  • 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();
    }
}
  • expiration auf 15 Minuten setzen — Access Tokens sollten kurzlebig sein.
  • signWith(key) wählt automatisch einen passenden Algorithmus basierend auf dem Schlüsseltyp, verhindert, dass der Client alg beeinflussen 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 HS256 erlauben). Vertrau niemals dem alg-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 TokenVerifier aus 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 localStorage bei einer öffentlichen SPA — das ist anfällig für XSS-Diebstahl. Bevorzuge das Speichern in HttpOnly-, 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 alg vom Token nicht.
  • Access Token kurzlebig (≤ 15 Minuten), Refresh Token zum Widerrufen vorhanden.
  • Keine sensiblen Daten in der Payload.
  • exp, iss, aud validieren, wenn nötig.
  • Token in HttpOnly-Cookie statt localStorage speichern, 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.

jwtweb-securityspring-bootauthenticationjava