# Spring Security with JWT — Login, Roles and Stateless APIs

Source: https://skills101.in/learn/spring-boot/spring-security-jwt — Skills101 free tutorial (Advanced Java & Spring Boot). Updated 2026-11-09.

Right now anyone could delete our courses. We need **authentication** (who are you?) and **authorisation** (what may you do?). For REST APIs used by apps, the standard answer is a **JWT** sent with every request.

![Login once with email and password to get a token, then send Authorization Bearer token with every request; the server answers 200, 401 or 403](https://skills101.in/learn-img/spring-boot/jwt-flow.svg)

## 1. What is a JWT?

![A JWT has three Base64 parts: header with the algorithm, payload with claims like sub, scope and exp, and a signature made with the server's secret](https://skills101.in/learn-img/spring-boot/jwt-parts.svg)

A **JSON Web Token** is `header.payload.signature`. The payload holds **claims** — who the user is (`sub`), their roles (`scope`), the issuer and the expiry (`exp`). The server signs it with a secret; if anyone changes one character, the signature check fails.

- **Stateless**: the server stores no session — perfect for many servers behind a load balancer and for mobile apps.
- The payload is only **encoded, not encrypted**: anyone can read it. Never put passwords or secrets in it.
- Keep tokens **short-lived** (we use 60 minutes); real apps add refresh tokens.

## 2. Security configuration

```java
// File: course-api/src/main/java/com/skills101/courses/security/SecurityConfig.java
package com.skills101.courses.security;

import java.util.Base64;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())   // no cookies or sessions: JWTs aren't sent automatically, so CSRF can't happen
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/token", "/actuator/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/courses/**").permitAll()
                .requestMatchers(HttpMethod.POST, "/api/courses/*/enrollments").hasAuthority("SCOPE_STUDENT")
                .requestMatchers("/api/courses/**").hasAuthority("SCOPE_ADMIN")
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { }));   // read "Authorization: Bearer <token>"
        return http.build();
    }

    @Bean
    SecretKey jwtKey(@Value("${app.jwt.secret}") String secret) {
        return new SecretKeySpec(Base64.getDecoder().decode(secret), "HmacSHA256");
    }

    @Bean
    JwtEncoder jwtEncoder(SecretKey jwtKey) {
        return NimbusJwtEncoder.withSecretKey(jwtKey).build();
    }

    @Bean
    JwtDecoder jwtDecoder(SecretKey jwtKey) {
        return NimbusJwtDecoder.withSecretKey(jwtKey).build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();   // bcrypt by default
    }

    // Demo users. A real app loads users from the database with its own UserDetailsService.
    @Bean
    UserDetailsService users(PasswordEncoder encoder) {
        return new InMemoryUserDetailsManager(
            User.withUsername("admin@skills101.in").password(encoder.encode("admin123")).roles("ADMIN").build(),
            User.withUsername("asha@skills101.in").password(encoder.encode("asha123")).roles("STUDENT").build(),
            User.withUsername("ravi@skills101.in").password(encoder.encode("ravi123")).roles("STUDENT").build());
    }

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

Line by line:

- **`SecurityFilterChain`** — the URL rules, checked top to bottom: login, health and API docs are public; anyone may GET courses; only `STUDENT` tokens may enrol; every other `/api/courses/**` call (POST, PUT, DELETE) needs `ADMIN`.
- **`csrf.disable()`** — CSRF attacks abuse cookies a browser sends automatically. We use no cookies or sessions (the token goes in a header), so CSRF protection isn't needed here. Keep it on for cookie-based web apps.
- **`STATELESS`** — never create an HTTP session.
- **`oauth2ResourceServer(… jwt …)`** — Spring Security's built-in support reads `Authorization: Bearer <token>`, verifies the signature and expiry with our `JwtDecoder`, and turns the `scope` claim into authorities like `SCOPE_ADMIN`. No hand-written filter needed.
- **Passwords** are stored hashed with **bcrypt** (a slow, salted hash) — never in plain text.
- The demo users live in memory; a real app implements `UserDetailsService` to load users from the database.

## 3. Issuing the token at login

```java
// File: course-api/src/main/java/com/skills101/courses/security/TokenService.java
package com.skills101.courses.security;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;

@Service
public class TokenService {

    private final JwtEncoder encoder;
    private final long ttlMinutes;

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

    public String issue(Authentication user) {
        // ROLE_ADMIN → "ADMIN"; Spring turns the "scope" claim back into SCOPE_ADMIN when reading the token
        String scope = user.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .map(a -> a.replaceFirst("^ROLE_", ""))
                .collect(Collectors.joining(" "));
        Instant now = Instant.now();
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("skills101-course-api")
                .subject(user.getName())
                .issuedAt(now)
                .expiresAt(now.plus(ttlMinutes, ChronoUnit.MINUTES))
                .claim("scope", scope)
                .build();
        JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
        return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
    }
}
```

```java
// File: course-api/src/main/java/com/skills101/courses/security/AuthController.java
package com.skills101.courses.security;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AuthController {

    public record LoginRequest(@NotBlank String email, @NotBlank String password) { }
    public record TokenResponse(String token, String type) { }

    private final AuthenticationManager authManager;
    private final TokenService tokens;

    public AuthController(AuthenticationManager authManager, TokenService tokens) {
        this.authManager = authManager;
        this.tokens = tokens;
    }

    @PostMapping("/auth/token")
    public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest req) {
        try {
            Authentication user = authManager.authenticate(
                    UsernamePasswordAuthenticationToken.unauthenticated(req.email(), req.password()));
            return ResponseEntity.ok(new TokenResponse(tokens.issue(user), "Bearer"));
        } catch (AuthenticationException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();   // same answer for wrong email or password
        }
    }
}
```

Calling it:

```text
POST /auth/token
{ "email": "admin@skills101.in", "password": "admin123" }

200 OK
{ "token": "eyJraWQiOiJQNUM0…", "type": "Bearer" }
```

A wrong password gives **401** with no hint whether the email exists (that would help attackers).

## 4. Who is calling?

Controllers receive the verified token with `@AuthenticationPrincipal`:

```java
// File: course-api/src/main/java/com/skills101/courses/enrollment/EnrollmentController.java
package com.skills101.courses.enrollment;

import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class EnrollmentController {

    private final EnrollmentService service;

    public EnrollmentController(EnrollmentService service) {
        this.service = service;
    }

    // the logged-in student comes from the JWT, never from the request body
    @PostMapping("/api/courses/{courseId}/enrollments")
    @ResponseStatus(HttpStatus.CREATED)
    public EnrollmentResponse enroll(@PathVariable Long courseId, @AuthenticationPrincipal Jwt jwt) {
        return service.enroll(courseId, jwt.getSubject());
    }

    @GetMapping("/api/me/courses")
    public List<String> myCourses(@AuthenticationPrincipal Jwt jwt) {
        return service.myCourses(jwt.getSubject());
    }
}
```

The student's identity comes from the **signed token**, never from the request body — otherwise anyone could enrol "as" someone else.

## 5. What our tests prove

| Request | Result |
|---|---|
| `GET /api/courses` with no token | 200 |
| `POST /api/courses` with no token | 401 Unauthorized |
| `POST /api/courses` with a STUDENT token | 403 Forbidden |
| `POST /api/courses` with an ADMIN token | 201 Created |
| login with a wrong password | 401 |

## Common mistakes

- Storing plain-text passwords, or hashing with MD5/SHA-1 (too fast) instead of bcrypt/argon2.
- A short or committed JWT secret — use 32+ random bytes from an environment variable.
- Trusting a user id sent in the JSON body instead of the token.
- Disabling CSRF in a cookie/session-based web app.
- 401 vs 403 mix-ups: 401 = not authenticated, 403 = authenticated but not allowed.

## Quick recap

- JWT = signed `header.payload.signature`; stateless; readable, so no secrets inside.
- `SecurityFilterChain` holds URL rules; `oauth2ResourceServer().jwt()` validates Bearer tokens.
- Hash passwords with bcrypt; issue tokens with `JwtEncoder`; read the user with `@AuthenticationPrincipal Jwt`.
- 401 = who are you? 403 = you can't do that.


Practice quiz and free certificate: https://skills101.in/learn/spring-boot/spring-security-jwt