REST-API mit Spring Security und JWT schützen

1. Übersicht

REST-APIs verwenden normalerweise keine Sessions. Jeder Request muss seine Authentifizierungsinformationen selbst mitbringen, und JWT (JSON Web Token) ist der häufigste Weg dafür.

Dieser Artikel zeigt dir, wie du eine Bestell-API mit Spring Security 6 und Spring Boot 3 schützt. Wir werden JWT beim Benutzer-Login ausstellen, Tokens bei jedem Request authentifizieren und basierend auf Rollen autorisieren. Am Ende schreiben wir automatisierte Tests für die Hauptfunktionen.

Wir schreiben keinen JWT-Filter von Hand. Stattdessen nutzen wir OAuth2 Resource Server, das bereits in Spring Security enthalten ist. Das ist weniger Code und sicherer.

2. Maven-Dependencies

Du brauchst die folgenden Dependencies in pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Das Starter oauth2-resource-server bringt die Nimbus-JOSE- und JWT-Bibliotheken mit. Diese Bibliotheken kümmern sich um das Signieren und Verifizieren von Token-Signaturen. Du brauchst nicht zusätzlich jjwt oder andere JWT-Bibliotheken.

3. Geheimen Schlüssel konfigurieren

Zum Vereinfachen signieren wir Tokens mit dem symmetrischen Algorithmus HS256. Der gleiche geheime Schlüssel wird zum Signieren und zur Signaturverifizierung verwendet.

jwt.secret=${JWT_SECRET:ersetze-das-durch-einen-geheimen-schluessel-mindestens-32-byte}
jwt.expiration-minutes=15

Die Ausdrücke ${JWT_SECRET:...} lesen den Schlüssel aus der Umgebungsvariable JWT_SECRET. Falls diese Variable nicht existiert, nutzt Spring den Standardwert nach dem Doppelpunkt. Der Standardwert ist nur für die Entwicklungsumgebung gedacht.

Hinweis: HS256 erfordert einen Schlüssel mit mindestens 256 Bit, also 32 Byte. Ist der Schlüssel kürzer, wirft Nimbus beim ersten Signieren sofort KeyLengthException.

4. Spring Security konfigurieren

4.1. JwtEncoder, JwtDecoder und Benutzer

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Value("${jwt.secret}")
    private String secret;

    @Bean
    SecretKey jwtSecretKey() {
        return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    }

    @Bean
    JwtEncoder jwtEncoder(SecretKey key) {
        return new NimbusJwtEncoder(new ImmutableSecret<>(key));
    }

    @Bean
    JwtDecoder jwtDecoder(SecretKey key) {
        return NimbusJwtDecoder.withSecretKey(key)
                .macAlgorithm(MacAlgorithm.HS256)
                .build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    UserDetailsService userDetailsService(PasswordEncoder encoder) {
        var customer = User.withUsername("alice")
                .password(encoder.encode("alice123")).roles("CUSTOMER").build();
        var admin = User.withUsername("bob")
                .password(encoder.encode("bob123")).roles("CUSTOMER", "ADMIN").build();
        return new InMemoryUserDetailsManager(customer, admin);
    }

    @Bean
    AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
        var provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(uds);
        provider.setPasswordEncoder(encoder);
        return new ProviderManager(provider);
    }
}

Die Rolle jedes Beans:

  • SecretKey wickelt den geheimen String in einen HMAC-SHA256-Schlüssel ein.
  • JwtEncoder nutzt diesen Schlüssel zum Signieren von Tokens beim Benutzer-Login.
  • JwtDecoder verifiziert die Signatur und die Gültigkeitsdauer (exp) des Tokens bei jedem Request. Der Resource Server verwendet dieses Bean automatisch.
  • InMemoryUserDetailsManager erstellt zwei Beispielbenutzer. In der Praxis ersetzt du es durch eine Implementierung, die aus einer Datenbank liest.
  • AuthenticationManager verifiziert Benutzername und Passwort im Login-Endpoint.

4.2. SecurityFilterChain

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
    return http.build();
}

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    var authorities = new JwtGrantedAuthoritiesConverter();
    authorities.setAuthoritiesClaimName("roles");
    authorities.setAuthorityPrefix("");

    var converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authorities);
    return converter;
}

Dieses Bean liegt in der gleichen Klasse SecurityConfig. Jeder Teil macht folgendes:

  • csrf.disable(): Die API nutzt keine Cookies zur Authentifizierung, daher ist sie nicht anfällig für CSRF-Attacken.
  • STATELESS: Spring erstellt keine HttpSession. Jeder Request wird neu vom Token authentifiziert.
  • authorizeHttpRequests: Der Login-Endpoint ist öffentlich. /api/admin/** ist nur für die Rolle ADMIN. Alle anderen Endpoints erfordern Authentifizierung.
  • oauth2ResourceServer(...jwt...): aktiviert einen Filter, der den Header Authorization: Bearer <token> liest und den Token mit JwtDecoder dekodiert.

Standardmäßig liest Spring Berechtigungen aus dem scope-Claim und fügt das Präfix SCOPE_ hinzu. Wir wollen hasRole("ADMIN") nutzen, daher wird JwtAuthenticationConverter so konfiguriert, dass es den roles-Claim liest. Das Präfix wird weggelassen, weil die Werte im Token bereits die Form ROLE_ADMIN haben.

5. Tokens beim Login ausstellen

5.1. TokenService

@Service
public class TokenService {

    private final JwtEncoder encoder;
    private final long expirationMinutes;

    public TokenService(JwtEncoder encoder,
                        @Value("${jwt.expiration-minutes}") long expirationMinutes) {
        this.encoder = encoder;
        this.expirationMinutes = expirationMinutes;
    }

    public String generateToken(Authentication authentication) {
        Instant now = Instant.now();
        List<String> roles = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("nonla-shop")
                .issuedAt(now)
                .expiresAt(now.plus(expirationMinutes, ChronoUnit.MINUTES))
                .subject(authentication.getName())
                .claim("roles", roles)
                .build();

        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
        return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }

    public long getExpirationSeconds() {
        return expirationMinutes * 60;
    }
}

Der Token enthält folgende Claims: Aussteller (iss), Ausstellungszeit (iat), Ablaufzeit (exp), Benutzername (sub) und eine Liste von Rollen.

Die Zeile JwsHeader.with(MacAlgorithm.HS256) ist zwingend erforderlich. Ohne sie wählt NimbusJwtEncoder standardmäßig RS256. Dann findet es keinen passenden Schlüssel und wirft den Fehler Failed to select a JWK signing key.

5.2. AuthController

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

    private final AuthenticationManager authenticationManager;
    private final TokenService tokenService;

    public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
        this.authenticationManager = authenticationManager;
        this.tokenService = tokenService;
    }

    public record LoginRequest(String username, String password) {}
    public record TokenResponse(String accessToken, String tokenType, long expiresIn) {}

    @PostMapping("/login")
    public TokenResponse login(@RequestBody LoginRequest request) {
        Authentication auth = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(
                        request.username(), request.password()));
        String token = tokenService.generateToken(auth);
        return new TokenResponse(token, "Bearer", tokenService.getExpirationSeconds());
    }
}

authenticate() verifiziert Benutzername und Passwort über DaoAuthenticationProvider. Wenn sie falsch sind, wirft die Methode BadCredentialsException. Spring Security übersetzt das zu HTTP 401. Wenn sie korrekt sind, erstellt der Controller einen Token und gibt ihn an den Client zurück.

Versuch einen Aufruf mit curl:

curl -s -X POST localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"alice123"}'
{"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJub25sYS1zaG9wIiwic3ViIjoiYWxpY2UiLC...","tokenType":"Bearer","expiresIn":900}

6. Geschützte Endpoints

@RestController
@RequestMapping("/api")
public class OrderController {

    public record Order(long id, String customer, BigDecimal total) {}

    private final List<Order> orders = List.of(
            new Order(1, "alice", new BigDecimal("250000")),
            new Order(2, "bob", new BigDecimal("990000")));

    @GetMapping("/orders")
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        return orders.stream()
                .filter(o -> o.customer().equals(jwt.getSubject()))
                .toList();
    }

    @GetMapping("/admin/orders")
    public List<Order> allOrders() {
        return orders;
    }
}

@AuthenticationPrincipal Jwt jwt injiziert den authentifizierten Token in den Controller. Du erhältst den Benutzernamen mit jwt.getSubject(). Um andere Claims zu lesen, nutzt du jwt.getClaim("roles").

Versuche Aufrufe mit drei verschiedenen Szenarien:

# Kein Token
curl -i localhost:8080/api/orders

# alice hat einen gültigen Token
curl -s localhost:8080/api/orders -H "Authorization: Bearer $TOKEN"

# alice ruft den Admin-Endpoint auf
curl -i localhost:8080/api/admin/orders -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401
WWW-Authenticate: Bearer

[{"id":1,"customer":"alice","total":250000}]

HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", ...

Das Ergebnis zeigt zwei verschiedene Fehlercodes. 401 bedeutet nicht authentifiziert: kein Token, falsche Signatur oder Token abgelaufen. 403 bedeutet authentifiziert, aber unzureichende Berechtigung.

7. Tests schreiben

spring-security-test stellt einen Post-Processor jwt() bereit. Es simuliert einen authentifizierten JWT, ohne einen echten Token zu signieren. Wir kombinieren es mit einem End-to-End-Test mit echtem Login.

@SpringBootTest
@AutoConfigureMockMvc
class OrderSecurityTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    void whenNoToken_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/orders")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenLoginAndCallWithToken_thenReturnOwnOrders() throws Exception {
        String body = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"username":"alice","password":"alice123"}
                                """))
                .andReturn().getResponse().getContentAsString();
        String token = objectMapper.readTree(body).get("accessToken").asText();

        MvcResult result = mockMvc.perform(get("/api/orders")
                        .header("Authorization", "Bearer " + token))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(200);
        assertThat(result.getResponse().getContentAsString())
                .contains("\"customer\":\"alice\"")
                .doesNotContain("\"customer\":\"bob\"");
    }

    @Test
    void whenWrongPassword_thenUnauthorized() throws Exception {
        MvcResult result = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"username\":\"alice\",\"password\":\"sai\"}"))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(401);
    }

    @Test
    void whenCustomerCallsAdminEndpoint_thenForbidden() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/admin/orders")
                        .with(jwt().jwt(j -> j.subject("alice"))
                                .authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
                .andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(403);
    }
}

Die statischen Imports, die du brauchst, sind MockMvcRequestBuilders.*, SecurityMockMvcRequestPostProcessors.jwt und Assertions.assertThat von AssertJ.

Der zweite Test überprüft den gesamten Ablauf. Er ruft den Login-Endpoint auf, erhält einen Token und nutzt ihn dann für API-Aufrufe. Dieser Test deckt Konfigurationsfehler von Encoder und Decoder auf, z.B. vergessenes JwsHeader oder zu kurzer Schlüssel.

Der letzte Test nutzt jwt(), um die Autorisierung isoliert zu überprüfen. Beachte, dass .authorities(...) den Converter überschreibt, daher musst du den korrekten Authority-Namen ROLE_CUSTOMER übergeben.

8. Häufige Fehler und wann man diesen Ansatz vermeidet

Häufige Fehler:

  • Zu kurzer Schlüssel: Schlüssel unter 32 Byte führen zu KeyLengthException bei HS256.
  • HS256-Header vergessen: JwtEncoder meldet Failed to select a JWK signing key.
  • Falsches Authority-Präfix: Mit dem Standard-Converter haben Berechtigungen die Form SCOPE_.... Dann wird hasRole("ADMIN") immer 403 zurückgeben. Stelle sicher, dass Claim und Präfix übereinstimmen.
  • Secret hardcodiert im Repo: Lese das Secret immer aus einer Umgebungsvariable oder einem Secret Manager.
  • Sensible Daten im Payload: JWT-Payloads werden nur Base64URL-kodiert, nicht verschlüsselt. Jeder, der den Token hat, kann den Inhalt lesen. Speichere keine Passwörter, Kartennummern oder persönliche Daten im Token.

Hinweis: JWTs können vor Ablauf nicht widerrufen werden, es sei denn, du fügst einen zusätzlichen Mechanismus wie eine Blacklist hinzu. Halte Access-Tokens kurzlebig (5–15 Minuten). Falls du lange Sessions brauchst, nutze zusätzlich einen Refresh-Token, der auf dem Server gespeichert ist, um ihn widerrufen zu können.

Wann du diesen Ansatz nicht nutzen solltest:

  • Server-seitiges Rendering, wie mit Thymeleaf: Sessions mit Cookies sind einfacher und sicherer.
  • Logout oder Sperrung muss sofort wirksam sein: Sessions oder opaque Tokens mit Introspection passen besser.
  • Mehrere Services: Die Aufteilung eines HMAC-Schlüssels auf alle Services ist sehr riskant. In diesem Fall solltest du einen separaten Authorization Server nutzen, wie Spring Authorization Server oder Keycloak. Der Authorization Server signiert Tokens mit RSA oder EC, und die Services müssen nur spring.security.oauth2.resourceserver.jwt.issuer-uri konfigurieren, um den Public Key über JWKS zu holen.

9. Fazit

Dieser Artikel hat eine zustandslose REST-API mit JWT geschützt. Die Schritte waren:

  • NimbusJwtEncoder und NimbusJwtDecoder mit HS256-Schlüssel nutzen.
  • Tokens im /api/auth/login-Endpoint über AuthenticationManager ausstellen.
  • OAuth2 Resource Server den Bearer-Header automatisch authentifizieren lassen.
  • Den roles-Claim auf Authorities mappen, um hasRole zu nutzen.
  • 401-, 403- und Erfolgsfälle mit MockMvc und AssertJ testen.

Von dieser Grundlage aus kannst du InMemoryUserDetailsManager durch Benutzer aus einer Datenbank ersetzen, Refresh-Tokens hinzufügen oder zu asymmetrischen Schlüsseln wechseln, wenn dein System auf mehrere Services skaliert wird.

spring-bootspring-securityjwtrest-apijava