Skills101

Advanced Java & Spring Boot · Lesson 9 of 16 · Updated ·

Spring Security with JWT — Login, Roles and Stateless APIs

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

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

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

// 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

// 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();
    }
}
// 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:

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:

// 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

RequestResult
GET /api/courses with no token200
POST /api/courses with no token401 Unauthorized
POST /api/courses with a STUDENT token403 Forbidden
POST /api/courses with an ADMIN token201 Created
login with a wrong password401

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 what you learned

📝 Quiz: Security and JWT5 questions · instant answers & explanations · +10 XP · counts towards your free certificateTake the quiz →