How to Secure a Spring Boot REST API: Roles, Endpoints & JWT

How to Secure a Spring Boot REST API: Roles, Endpoints & JWT

Shipping a Spring Boot REST API without security is shipping a broken product. Every endpoint that reads, writes, or deletes data is accessible to anyone who can reach your server. No authentication means no way to know who is making requests. No authorization means no way to restrict what they can do. In production, this is not a missing feature - it is a critical vulnerability.

Spring Security is the standard solution for Spring Boot API security, and Spring Security 6 (introduced with Spring Boot 3) makes the implementation cleaner than ever. But it has a reputation for complexity - and for good reason. The filter chain, UserDetailsService, BCryptPasswordEncoder, SecurityFilterChain, JWT tokens, and @PreAuthorize all need to work together correctly. A single misconfiguration and everything either locks up or stays wide open.

This guide cuts through the complexity. It covers every security layer your REST API needs - from the Spring Security filter chain architecture through password encoding, role-based route protection, complete JWT authentication, and method-level access control. Every section builds the same production-ready API, so by the end you have a coherent, working security implementation - not a collection of disconnected snippets. For a deeper foundation on Spring Security's Java underpinnings, Board Infinity's guides on OOP concepts in Java and multiple inheritance and Java interfaces are ideal prerequisites - Spring Security's entire architecture is built on interface implementation and polymorphism.

Who This Guide Is For

This guide is for you if you:

  • Have a working Spring Boot REST API and need to add security to it
  • Understand Spring Boot basics (controllers, services, JPA) but haven't implemented Spring Security yet
  • Have added Spring Security and found all endpoints locked or all open - and need to understand why
  • Want a complete, production-quality security implementation with JWT and roles
  • Want to solidify the Java fundamentals that power Spring Security - Board Infinity's core Java concepts guide covers the Java foundation that makes Spring Security's annotation-driven, interface-based architecture make sense

1. Spring Security Architecture: Filters, Authentication & Authorization

Before writing a single line of security configuration, understand the architecture - otherwise the code won't make sense.

Spring Security works as a chain of servlet filters that intercepts every HTTP request before it reaches your controllers. The filters run in a specific order, and each filter decides whether to process the request, reject it with a 401/403, or pass it to the next filter.

The two core security concepts are authentication (who are you?) and authorization (what are you allowed to do?). Spring Security handles both, in that order. A request must first be authenticated before authorization rules are applied. Understanding how Java's servlet architecture underpins this filter chain is covered in Board Infinity's guide on servlets in Java - Spring Security's filter chain is a direct extension of the Java servlet filter model, and understanding servlets makes the OncePerRequestFilter pattern in JwtAuthFilter immediately intuitive.

Filter / Component What It Does Where You Interact With It
SecurityFilterChain Defines route-level security rules for all requests @Bean SecurityFilterChain in your config class
UsernamePasswordAuthenticationFilter Handles form-based login (not used in JWT REST APIs) Your JwtAuthFilter is added before this
JwtAuthFilter Custom filter: validates JWT on every request You write this as OncePerRequestFilter
UserDetailsService Loads user by username from your database You implement this interface in your service layer
SecurityContextHolder Stores the authenticated user for the current request Your filter sets auth here; controllers read from here
AuthenticationManager Validates credentials (username + password) Your login endpoint calls this to verify credentials
๐Ÿ”
Adding spring-boot-starter-security Locks Everything by Default

The moment you add spring-boot-starter-security to your project, Spring Security auto-configures a default SecurityFilterChain that requires authentication for every request. Every endpoint returns 401 until you configure your own SecurityFilterChain @Bean. This is intentional - Spring Security is "secure by default." Your job is to explicitly declare which routes are public.

2. Password Encoding with BCryptPasswordEncoder

Never store plain-text passwords in your database. This is not optional - it is the minimum security standard for any application that handles user authentication. Spring Security provides BCryptPasswordEncoder, which uses the BCrypt hashing algorithm to store passwords as irreversible hashes.

BCrypt is designed to be slow - it incorporates a configurable "cost factor" that makes brute-force attacks computationally expensive. Unlike MD5 or SHA-256, BCrypt is specifically designed for password hashing, not general-purpose hashing. The encapsulation principle that protects passwords in the User entity - keeping the password field private with no getPassword() getter exposed to the outside world - is the exact pattern covered in Board Infinity's guide on abstraction vs encapsulation in Java. Proper encapsulation is not just good OOP practice - it's a security requirement when dealing with sensitive user data.

Java - BCryptPasswordEncoder Setup and Usage
// Register PasswordEncoder as a Spring bean
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(); // default strength = 10
    // new BCryptPasswordEncoder(12) - higher strength, slower, more secure
}
}
// Using it in your registration service
@Service
public class UserService {
private final UserRepository   userRepository;
private final PasswordEncoder  passwordEncoder;

public UserService(UserRepository ur, PasswordEncoder pe) {
    this.userRepository  = ur;
    this.passwordEncoder = pe;
}

public User register(String email, String rawPassword) {
    if (userRepository.existsByEmail(email)) {
        throw new EmailAlreadyExistsException(email);
    }

    User user = new User();
    user.setEmail(email);
    user.setPassword(passwordEncoder.encode(rawPassword)); // NEVER store raw
    user.setRole(Role.ROLE_USER);
    return userRepository.save(user);
}

// Spring Security calls matches() during authentication - you don't call this
// passwordEncoder.matches(rawPassword, storedHash) returns true/false
}
div class="bi-tip warning"> โš ๏ธ
Never Compare Passwords Manually - Use passwordEncoder.matches()

BCrypt hashes are salted - the same password produces a different hash every time. "password123" might hash to $2a$10$abc... on one call and $2a$10$xyz... on the next. You can't compare hashes with equals(). Always use passwordEncoder.matches(rawPassword, storedHash) which handles the salt comparison correctly. Spring Security's AuthenticationManager calls this for you automatically during login.

3. Defining Users, Roles & Authorities in the Database

A production-grade security implementation stores users and their roles in a database - not hardcoded in configuration. This lets you manage users dynamically, assign different roles, and revoke access without redeploying.

Spring Security uses UserDetails (interface) and UserDetailsService (interface) to decouple your user model from Spring Security's authentication mechanism. You implement these interfaces using your own User entity. The interface implementation pattern here - where UserDetailsServiceImpl implements UserDetailsService - is a direct application of the abstraction principle. Board Infinity's guide on abstraction in Java explains precisely why Spring Security is designed around interfaces rather than concrete classes: it allows you to provide any implementation (database, LDAP, in-memory) without Spring Security knowing or caring about the details. The Java Comparator interface guide also provides useful context on how Java interfaces define behavioral contracts - the same pattern UserDetailsService uses to define the loading contract.

Java - User Entity, Role Enum, and UserDetailsService
// Role enum - defines available roles in the system
public enum Role {
    ROLE_USER,    // standard authenticated user
    ROLE_ADMIN,   // full administrative access
    ROLE_MANAGER  // intermediate access level
}
// User entity - maps to "users" table
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long   id;

@Column(unique = true, nullable = false)
private String email;

@Column(nullable = false)
private String password; // BCrypt hash - never plain text

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role   role;

@Column(nullable = false)
private boolean enabled = true; // allows account deactivation

// getters, setters, constructors...
}
// UserDetailsService - bridges your User entity and Spring Security
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;

public UserDetailsServiceImpl(UserRepository userRepository) {
    this.userRepository = userRepository;
}

@Override
public UserDetails loadUserByUsername(String email)
        throws UsernameNotFoundException {

    User user = userRepository.findByEmail(email)
        .orElseThrow(() ->
            new UsernameNotFoundException("User not found: " + email));

    // Convert your User entity to Spring Security's UserDetails
    return org.springframework.security.core.userdetails.User
        .withUsername(user.getEmail())
        .password(user.getPassword())        // BCrypt hash from DB
        .roles(user.getRole().name()
            .replace("ROLE_", ""))           // Spring adds ROLE_ prefix back
        .accountExpired(false)
        .accountLocked(false)
        .credentialsExpired(false)
        .disabled(!user.isEnabled())
        .build();
}
}

4. Protecting Routes: permitAll() vs hasRole()

The SecurityFilterChain is where you declare which URLs are publicly accessible and which require authentication or specific roles. The order of rules matters - Spring Security evaluates them from top to bottom and applies the first match.

The role-based access control in SecurityFilterChain - checking ROLE_ADMIN, ROLE_USER, ROLE_MANAGER - relies on Java's Set of GrantedAuthority objects internally. Understanding how sets handle role collections (no duplicates, efficient lookup) is covered in Board Infinity's guide on HashSet in Java. Spring Security uses Set<GrantedAuthority> specifically because roles are unique - a user cannot have duplicate authorities - which is exactly the semantic guarantee a Set provides. The access modifiers in Java guide is also relevant here: understanding private, protected, and public access control at the Java level builds the mental model for thinking about access control at the API level.

Java - SecurityFilterChain with Route Protection Rules
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize on methods
public class SecurityConfig {
private final JwtAuthFilter          jwtAuthFilter;
private final UserDetailsServiceImpl  userDetailsService;

public SecurityConfig(JwtAuthFilter jwtFilter,
                       UserDetailsServiceImpl uds) {
    this.jwtAuthFilter     = jwtFilter;
    this.userDetailsService = uds;
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
        throws Exception {

    return http
        // Disable CSRF - REST APIs use JWT tokens, not cookies
        .csrf(csrf -> csrf.disable())

        // Stateless - no sessions created or used
        .sessionManagement(sm -> sm
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))

        // Route-level authorization rules - evaluated TOP TO BOTTOM
        .authorizeHttpRequests(auth -> auth

            // Open to everyone - no token required
            .requestMatchers("/api/auth/**").permitAll()
            .requestMatchers("/actuator/health").permitAll()
            .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()

            // ADMIN only - requires ROLE_ADMIN in JWT
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")

            // MANAGER or ADMIN
            .requestMatchers("/api/reports/**").hasAnyRole("ADMIN", "MANAGER")

            // Everything else requires valid JWT (any role)
            .anyRequest().authenticated()
        )

        // Register JWT filter before Spring's default auth filter
        .addFilterBefore(jwtAuthFilter,
            UsernamePasswordAuthenticationFilter.class)

        .build();
}

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

@Bean
public AuthenticationManager authenticationManager(
        AuthenticationConfiguration config) throws Exception {
    return config.getAuthenticationManager();
}
}
Rule What It Does HTTP Status When Failed Common Use
permitAll() No authentication required - fully public Never fails on its own Login, register, public product listing
authenticated() Requires any valid JWT token 401 if no/invalid token User profile, order history, protected pages
hasRole("ADMIN") Requires ROLE_ADMIN in token 403 if wrong role, 401 if no token Admin dashboard, user management, delete ops
hasAnyRole("X","Y") Requires ROLE_X or ROLE_Y in token 403 if neither role present Shared admin/manager operations
hasAuthority("...") Matches a specific authority string exactly 403 if authority absent Fine-grained permissions (read:products etc.)
denyAll() Rejects all requests regardless of auth 403 always Temporarily disabling an endpoint
--- END HTML BLOCK --- --- HTML BLOCK: TIP BOX ---
๐Ÿ“Œ
Order Matters - More Specific Rules Must Come First

Spring Security applies the first matching rule. If you put .anyRequest().authenticated() before .requestMatchers("/api/admin/**").hasRole("ADMIN"), every request to /api/admin/ only needs to be authenticated - not admin. Always put more specific rules (role-based, path-specific) before broader rules (anyRequest()). anyRequest() should always be last.

5. JWT Token Flow: Login - Token - Secure Request

The complete JWT authentication flow has three steps that work together: the client logs in and receives a token, the client includes the token in subsequent requests, and the server validates the token and authenticates the user on each request.

The JwtUtils class handles two responsibilities - generating tokens on login and validating them on each request. The method chaining in token generation (Jwts.builder().subject().claim().issuedAt()...) uses the Builder pattern - a Java design pattern that creates complex objects step by step. Understanding how Java's method chaining works with builder patterns connects to Board Infinity's what is composition in Java guide, which covers how objects are assembled from parts - the same principle the JWT builder uses to assemble a token from claims, signature, and expiry.

The stream() and collect() operations used to extract roles from getAuthorities() apply the Java Streams API directly in security code. Board Infinity's map stream in Java guide covers these stream operations - the map() and collect() pattern that transforms GrantedAuthority objects into role name strings for the JWT payload.

Java - JwtUtils: Generate and Validate Tokens
@Component
public class JwtUtils {
@Value("${app.jwt.secret}")
private String secret;

@Value("${app.jwt.expiry-ms}")
private long expiryMs;

public String generateToken(UserDetails userDetails) {
    return Jwts.builder()
        .subject(userDetails.getUsername())
        .claim("roles", userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.toList()))
        .issuedAt(new Date())
        .expiration(new Date(System.currentTimeMillis() + expiryMs))
        .signWith(getKey())
        .compact();
}

public String  extractUsername(String token) {
    return claims(token).getSubject();
}

public boolean isValid(String token, UserDetails user) {
    try {
        return extractUsername(token).equals(user.getUsername())
            && !claims(token).getExpiration().before(new Date());
    } catch (JwtException e) {
        return false; // expired, malformed, invalid signature
    }
}

private Claims claims(String token) {
    return Jwts.parser().verifyWith(getKey()).build()
        .parseSignedClaims(token).getPayload();
}

private SecretKey getKey() {
    return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
}
// Login endpoint - returns token on success
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authManager;
private final UserDetailsService   userDetailsService;
private final JwtUtils             jwtUtils;

public AuthController(AuthenticationManager am,
                       UserDetailsService uds,
                       JwtUtils ju) {
    this.authManager        = am;
    this.userDetailsService = uds;
    this.jwtUtils           = ju;
}

@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(
        @Valid @RequestBody LoginRequest req) {

    // Throws BadCredentialsException if invalid - returns 401 via exception handler
    authManager.authenticate(
        new UsernamePasswordAuthenticationToken(req.email(), req.password()));

    UserDetails user  = userDetailsService.loadUserByUsername(req.email());
    String      token = jwtUtils.generateToken(user);

    return ResponseEntity.ok(Map.of(
        "token",  token,
        "email",  req.email(),
        "type",   "Bearer"
    ));
}
}

The JwtAuthFilter

Every request after login includes the JWT in the Authorization: Bearer {token} header. The filter validates it and sets the security context.

Java - JwtAuthFilter: Validates Token on Every Request
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtils           jwtUtils;
private final UserDetailsService userDetailsService;

public JwtAuthFilter(JwtUtils ju, UserDetailsService uds) {
    this.jwtUtils           = ju;
    this.userDetailsService = uds;
}

@Override
protected void doFilterInternal(HttpServletRequest req,
                                   HttpServletResponse res,
                                   FilterChain chain)
        throws ServletException, IOException {

    final String header = req.getHeader("Authorization");

    // No token - continue without authenticating
    if (header == null || !header.startsWith("Bearer ")) {
        chain.doFilter(req, res);
        return;
    }

    final String token    = header.substring(7);
    final String username = jwtUtils.extractUsername(token);

    // Only process if username found and no existing auth in context
    if (username != null &&
            SecurityContextHolder.getContext().getAuthentication() == null) {

        UserDetails user = userDetailsService.loadUserByUsername(username);

        if (jwtUtils.isValid(token, user)) {
            UsernamePasswordAuthenticationToken authToken =
                new UsernamePasswordAuthenticationToken(
                    user, null, user.getAuthorities());
            authToken.setDetails(
                new WebAuthenticationDetailsSource().buildDetails(req));

            SecurityContextHolder.getContext().setAuthentication(authToken);
        }
    }
    chain.doFilter(req, res);
}
}

6. Method-Level Security with @PreAuthorize

@PreAuthorize provides fine-grained access control at the individual method level. While SecurityFilterChain rules apply to URL patterns, @PreAuthorize applies to specific controller methods - and can use Spring Expression Language (SpEL) for complex conditions like checking whether the authenticated user owns the resource they're accessing.

The isOwner() method called from @PreAuthorize's SpEL expression uses Optional.map().orElse() - the exact Optional chaining pattern covered in Java Streams guides. Board Infinity's Java List guide and throw and throws in Java guide provide the foundational Java knowledge that makes method-level security implementation readable - understanding how collections are processed and how exceptions are thrown and propagated through the security layer is essential for writing robust @PreAuthorize expressions. The overloading vs overriding guide is also relevant: @Override on loadUserByUsername in UserDetailsServiceImpl is a critical method override - getting it wrong produces runtime failures that are difficult to debug without understanding Java's method override contract.

Java - @PreAuthorize Examples: Roles, Ownership, and Expressions
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;

public UserController(UserService userService) {
    this.userService = userService;
}

// Any authenticated user can view their own profile
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")
public UserDto getMyProfile(Authentication auth) {
    return userService.findByEmail(auth.getName()); // auth.getName() = email from JWT
}

// Only ADMIN can list all users
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public Page<UserDto> getAllUsers(Pageable pageable) {
    return userService.findAll(pageable);
}

// User can only update their own data; admins can update anyone's
@PutMapping("/{id}")
@PreAuthorize("@userService.isOwner(#id, authentication.name) or hasRole('ADMIN')")
public UserDto updateUser(@PathVariable Long id,
                           @Valid @RequestBody UpdateUserRequest req) {
    return userService.update(id, req);
}

// Only ADMIN can delete users
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
    userService.delete(id);
}
}
// The isOwner() method referenced in @PreAuthorize SpEL expression
@Service
public class UserService {
private final UserRepository userRepository;

public boolean isOwner(Long userId, String email) {
    return userRepository.findById(userId)
        .map(u -> u.getEmail().equals(email))
        .orElse(false);
}
}
๐Ÿ’ก
Handle Security Exceptions Globally in @RestControllerAdvice

Add two exception handlers to your @RestControllerAdvice: one for AuthenticationException (returns 401 - not authenticated) and one for AccessDeniedException (returns 403 - authenticated but wrong role). Without these, Spring Security returns its default HTML error pages or inconsistent JSON structures for security failures. Your API clients need consistent JSON error responses for all failure scenarios.

7. Testing Secured Endpoints

With your security implementation in place, test every security scenario systematically. A security implementation that hasn't been tested for its failure cases is not a security implementation - it's a hope.

Here are the exact tests to run in Postman or any HTTP client: Test 1 - Public endpoint without token. Test 2 - Protected endpoint without token (expect 401). Test 3 - Login and receive token. Test 4 - Protected endpoint with valid token (expect 200). Test 5 - Admin endpoint with USER token (expect 403). Test 6 - Expired or tampered token (expect 401). The complete picture of how Spring Boot REST APIs are built - the foundation into which this security layer is added - is covered in Board Infinity's Essentials of Back-End Development: From APIs to Databases, which explains the full backend architecture that security protects.

๐Ÿ”
Use jwt.io to Inspect Your Tokens During Testing

Paste your JWT token into jwt.io to decode and inspect its contents. You can verify the header (algorithm), payload (username, roles, expiry), and check whether the signature validates against your secret. This is invaluable when debugging authentication issues - it shows you exactly what information is inside the token your filter is processing.

Further Reading

Board Infinity Guides:

External Resources:

๐Ÿš€ Build a Complete Secured Spring Boot API

Spring Boot, Spring Security & Application Finalization on Coursera

This free Coursera course by Board Infinity applies every security concept in this guide inside a complete, production-ready project. You'll build the entire security layer - BCrypt, roles, JWT, SecurityFilterChain, and @PreAuthorize - integrated with JPA, REST APIs, and a fully tested application.

Module 1
Spring Boot Foundations Auto-configuration, application.yml with profiles, JPA entities, relationships, and Spring Data repositories
Module 2
Building Full REST APIs with Spring Boot Service layer, controller layer, pagination, DTO mapping, exception handling, and API design patterns
Module 3
Spring Security & JWT Spring Security 6 filter chain, BCrypt password encoding, roles and authorities, SecurityFilterChain, JWT generation, JwtAuthFilter, and @PreAuthorize - every concept in this guide, hands on
Module 4
Final Application Build Integrating all layers, adding validation with security, logging, debugging, code cleanup, and preparing the full application for real-world deployment
Start Learning on Coursera โ†’

โœ“ Certificate available  ยท  โœ“ Self-paced  ยท  โœ“ Beginner-friendly

Conclusion

A secure Spring Boot REST API is built in layers. The SecurityFilterChain defines route-level access rules. The JwtAuthFilter validates tokens on every request and sets the authenticated user in the security context. UserDetailsService bridges your user database and Spring Security. BCryptPasswordEncoder ensures passwords are never stored in plain text. And @PreAuthorize provides method-level granularity for complex access patterns.

The security architecture in this guide is stateless by design - no sessions, no server-side state, just cryptographically signed tokens that carry all the information needed to verify a request. This makes your API horizontally scalable: any instance can validate any token without consulting a shared session store.

The most important discipline going forward: test every security boundary explicitly. Test the 401 responses. Test the 403 responses. Test with expired tokens. Test with tampered tokens. Security that hasn't been deliberately tested for failure cases is security that gives you false confidence. A systematically tested security layer - one where you've verified every rule fails correctly as well as succeeds - is the foundation every production Spring Boot API deserves. The Java fundamentals that underpin every part of this security implementation - particularly polymorphism through interfaces, encapsulation of sensitive data, and generics in collection types - are all covered comprehensively in Board Infinity's understanding polymorphism in Java guide, which explains the OOP concept that Spring Security's entire injection and interface-based architecture is built on.

Programming Language Java Spring Boot Web Development Spring Framework