Computer scienceProgramming languagesJavaInterview preparationTech interviewFrameworks and design

Spring web and security

28 minutes read

Spring is a dominant framework for building robust, scalable, production-grade applications. It simplifies web development with Spring Web and enforces strong security patterns through Spring Security. Interviewers commonly evaluate candidates on these to assess their ability to build secure, RESTful applications — a core skill in most backend or full-stack roles.

Spring Web

Spring MVC and Controllers

  • Spring MVC is based on the Model-View-Controller architecture, which helps organize code by separating the different parts of an application: input, UI, and business logic.

  • You can add it to a project using the spring-boot-starter-web dependency.

  • An embedded server is the component of a web app required to run it. The default server for Spring MVC is Apache Tomcat.

  • The default port is 8080, and the context path is empty. You can change the default port and context path using the application.properties file.

  • To help with implementing MVC, we will need a template engine. A template engine allows us to write static template files in our application. Three template engines commonly used in Spring are: JSP, Thymeleaf, Freemarker.

  • You can declare a Controller using annotations like @Controller, @RestController; and map URLs using @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, etc.

@RestController
@RequestMapping("/api")
class UserController {
    // Handles GET requests to /api/users
    @GetMapping("/users")
    fun getUsers(): List<String> = listOf("Alice", "Bob")
}
@RestController
@RequestMapping("/api")
public class UserController {
    // Handles GET requests to /api/users
    @GetMapping("/users")
    public List<String> getUsers() {
        return List.of("Alice", "Bob");
    }
}

Request Handling

  • Use @RequestBody to bind request JSON to model objects.

  • Use @PathVariable and @RequestParam for route and query parameters.

// Using @RequestBody: Accepts JSON payload to create a user
@PostMapping("/register")
fun register(@RequestBody user: User): ResponseEntity<String> { ... }

// Using @PathVariable: Fetches user details based on path ID
@GetMapping("/user/{id}")
fun getUser(@PathVariable id: Long): User { ... }

 // Using @RequestParam: Filters users by name via query parameter
@GetMapping("/search")
fun searchUserByName(@RequestParam name: String): List<User> { ... }
// Using @RequestBody: Accepts JSON payload to create a user
@PostMapping("/register")
public ResponseEntity<String> register(@RequestBody User user) { ... }

// Using @PathVariable: Fetches user details based on path ID
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) { ... }

// Using @RequestParam: Filters users by name via query parameter
@GetMapping("/search")
public List<User> searchUserByName(@RequestParam String name) { ... }

Exception Handling

  • Use @ControllerAdvice with @ExceptionHandler to manage errors globally.

    // @ControllerAdvice makes this class a global exception handler for all controllers
    @ControllerAdvice
    class GlobalExceptionHandler {
        // This method handles exceptions of type 
        // `UserNotFoundException` thrown in any controller
        // and returns a 404 Not Found response with a custom message
        @ExceptionHandler(UserNotFoundException::class)
        fun handleNotFound(): ResponseEntity<String> = ResponseEntity("User not found", HttpStatus.NOT_FOUND)
    }
    // @ControllerAdvice makes this class a global exception handler for all controllers
    @ControllerAdvice
    public class GlobalExceptionHandler {
        // This method handles exceptions of type 
        // `UserNotFoundException` thrown in any controller
        // and returns a 404 Not Found response with a custom message
        @ExceptionHandler(UserNotFoundException.class)
        public ResponseEntity<String> handleNotFound() {
            return new ResponseEntity<>("User not found", HttpStatus.NOT_FOUND);
        }
    }
  • In the @ControllerAdvice class, we can handle not only custom exceptions but also standard exceptions thrown by Spring itself, using ResponseEntityExceptionHandler abstract class.

    // Global exception handler for controller-related errors
    @ControllerAdvice
    class ControllerExceptionHandler : ResponseEntityExceptionHandler() {
        // Handles validation errors when @Valid fails on method arguments
        override fun handleMethodArgumentNotValid(
            ex: MethodArgumentNotValidException,
            headers: HttpHeaders,
            status: HttpStatusCode,
            request: WebRequest
        ): ResponseEntity<Any> {
            // Custom error response logic here
        }
    }
    // Global exception handler for controller-related errors
    @ControllerAdvice
    public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {
        // Handles validation errors when @Valid fails on method arguments
        @Override
        protected ResponseEntity<Object> handleMethodArgumentNotValid(
                MethodArgumentNotValidException ex,
                HttpHeaders headers,
                HttpStatusCode status,
                WebRequest request
        ) {
            // Custom error response logic here
        }
    }

Bean Validation

In web-based applications, a client can send data to the server. So, it is important to handle the cases if a user sends data which violates the business logic of the application. We do it using JSR-380 annotations like:

  • @NotNull, @NotEmpty, @NotBlank, @Size, @Min, @Max, @Pattern and @Email on model classes.

    class SpecialAgent(
        @NotNull
        var name: String?,
    
        @NotEmpty
        var motto: String,
    
        @NotBlank
        var status: String,
    
        @Size(min = 1, max = 3)
        var code: String,
    
        @Size(min = 0, max = 4)
        var cars: List<String>,
        
        @Min(value = 18)
        var age: Int,
        
        @Max(5)
        var numberOfCurrentMissions: Int,
        
        @Pattern(regexp = "[0-9]{1,3}")
        var anotherCode: String,
        
        @NotNull
        @Email
        var email: String?
    )
    public class SpecialAgent {
        @NotNull
        private String name;
    
        @NotEmpty
        private String motto;
    
        @NotBlank
        private String status;
    
        @Size(min = 1, max = 3)
        private String code;
    
        @Size(min = 0, max = 4)
        private List<String> cars;
    
        @Min(value = 18)
        private int age;
    
        @Max(5)
        private int numberOfCurrentMissions;
    
        @Pattern(regexp = "[0-9]{1,3}")
        private String anotherCode;
    
        @NotNull
        @Email
        private String email;
    
        // Getters and setters omitted for brevity
    }
  • Use the @Validated annotation for path variables and request parameters

    @RestController
    class SpecialAgentController {
        @PostMapping("/agent")
        fun validate(@RequestBody @Valid agent: SpecialAgent): ResponseEntity<String> {
            return ResponseEntity.ok("Agent info is valid.")
        }
    }
    @RestController
    public class SpecialAgentController {
        @PostMapping("/agent")
        public ResponseEntity<String> validate(@RequestBody @Valid SpecialAgent agent) {
            return ResponseEntity.ok("Agent info is valid.");
        }
    }
  • Use the @Valid annotation in controllers

    @RestController
    @Validated
    class SpecialAgentController {
        @GetMapping("/agents/{id}")
        fun validateAgentPathVariable(@PathVariable("id") @Min(1) id: Int): ResponseEntity<String> {
            return ResponseEntity.ok("Agent id is valid.")
        }
    
        @GetMapping("/agents")
        fun validateAgentRequestParam(
            @RequestParam("code") @Pattern(regexp = "[0-9]{1,3}") code: String
        ): ResponseEntity<String> {
            return ResponseEntity.ok("Agent code is valid.")
        }
    }
    @RestController
    @Validated
    public class SpecialAgentController {
        @GetMapping("/agents/{id}")
        public ResponseEntity<String> validateAgentPathVariable(
                @PathVariable("id") @Min(1) int id
        ) {
            return ResponseEntity.ok("Agent id is valid.");
        }
    
        @GetMapping("/agents")
        public ResponseEntity<String> validateAgentRequestParam(
                @RequestParam("code") @Pattern(regexp = "[0-9]{1,3}") String code
        ) {
            return ResponseEntity.ok("Agent code is valid.");
        }
    }

You can use all these annotations from the jakarta.validation package by adding the spring-boot-starter-validation dependency to your project.

REST and HTTP Communication

It's important to use the correct HTTP status codes to represent the outcome of operations. HTTP status codes are grouped into ranges based on their meaning:

  • 1xx (Informational) – Request received, continuing process (rarely used directly)

  • 2xx (Success) – Request was successfully received, understood, and accepted

    • 200 OK – successful GET or general request

    • 201 Created – resource created successfully (e.g., after POST)

    • 204 No Content – request successful, no content to return (e.g., after DELETE)

  • 3xx (Redirection) – Further action needed to complete the request

    • 301 Moved Permanently, 302 Found

  • 4xx (Client Error) – The client made an error

    • 400 Bad Request – malformed or invalid request

    • 401 Unauthorized – authentication failed or missing

    • 403 Forbidden – authenticated but not allowed

    • 404 Not Found – requested resource doesn’t exist

    • 409 Conflict – request conflicts with current state

  • 5xx (Server Error) – The server failed to fulfill a valid request

    • 500 Internal Server Error – general server error

    • 503 Service Unavailable – server is temporarily overloaded or down

Properly using these status codes helps clients understand what happened and how to handle the response.

For REST calls in Spring applications, use:

  • RestTemplate for synchronous/blocking operations. It provides convenient methods for performing the most common REST verbs, including GET, POST, PUT, and DELETE, making it easy to interact with a RESTful API.

    val restTemplate = RestTemplate()
    
    // GET: fetch data from an endpoint
    val getResponse = restTemplate.getForEntity("https://api.example.com/data", String::class.java)
    
    // POST: send data to create a new resource
    val requestBody = YourRequestObject(...)
    val postResponse = restTemplate.postForEntity("https://api.example.com/data", requestBody, String::class.java)
    
    // PUT: update an existing resource
    val updatedData = YourUpdatedObject(...)
    restTemplate.put("https://api.example.com/data/123", updatedData)
    
    // DELETE: remove a resource
    restTemplate.delete("https://api.example.com/data/123")
    RestTemplate restTemplate = new RestTemplate();
    
    // GET: fetch data from an endpoint
    ResponseEntity<String> getResponse
            = restTemplate.getForEntity("https://api.example.com/data", String.class);
    
    // POST: send data to create a new resource
    YourRequestObject requestBody = new YourRequestObject(...);
    ResponseEntity<String> postResponse
            = restTemplate.postForEntity("https://api.example.com/data", requestBody, String.class);
    
    // PUT: update an existing resource
    YourUpdatedObject updatedData = new YourUpdatedObject(...);
    restTemplate.put("https://api.example.com/data/123", updatedData);
    
    // DELETE: remove a resource
    restTemplate.delete("https://api.example.com/data/123");
  • WebClient for asynchronous and non-blocking operations (preferred in reactive apps). It is a non-blocking, reactive HTTP client provided by Spring WebFlux. It's recommended for making asynchronous HTTP requests or when you need better scalability, especially in reactive applications.

    val webClient = WebClient.create("https://api.example.com")
    
    // GET: fetch data from an endpoint
    val getResponse = webClient.get()
        .uri("/data")
        .retrieve()
        .bodyToMono(String::class.java)
        .block()  // block to get the result in a non-reactive environment (use carefully in production)
    
    // POST: send data to create a new resource
    val requestBody = YourRequestObject(...)
    val postResponse = webClient.post()
        .uri("/data")
        .bodyValue(requestBody)
        .retrieve()
        .bodyToMono(String::class.java)
        .block()
    
    // PUT: update an existing resource
    val updatedData = YourUpdatedObject(...)
    webClient.put()
        .uri("/data/123")
        .bodyValue(updatedData)
        .retrieve()
        .toBodilessEntity()
        .block()  // block if you want to wait for completion in a non-reactive way
    
    // DELETE: remove a resource
    webClient.delete()
        .uri("/data/123")
        .retrieve()
        .toBodilessEntity()
        .block()
    WebClient webClient = WebClient.create("https://api.example.com");
    
    // GET: fetch data from an endpoint
    String getResponse = webClient.get()
            .uri("/data")
            .retrieve()
            .bodyToMono(String.class)
            .block();  // block to get the result in a non-reactive environment (use carefully in production)
    
    // POST: send data to create a new resource
    YourRequestObject requestBody = new YourRequestObject(/*...*/);
    String postResponse = webClient.post()
            .uri("/data")
            .bodyValue(requestBody)
            .retrieve()
            .bodyToMono(String.class)
            .block();
    
    // PUT: update an existing resource
    YourUpdatedObject updatedData = new YourUpdatedObject(/*...*/);
    webClient.put()
            .uri("/data/123")
            .bodyValue(updatedData)
            .retrieve()
            .toBodilessEntity()
            .block();  // block if you want to wait for completion in a non-reactive way
    
    // DELETE: remove a resource
    webClient.delete()
            .uri("/data/123")
            .retrieve()
            .toBodilessEntity()
            .block();

Spring Security

At some point, you'll want to protect your application and differentiate users to personalize content or enforce access control. Spring Security is a module of the Spring Framework that handles authentication and authorization.

Spring Security acts as a security layer between the client and your application, intercepting all incoming requests and allowing you to configure which users can access which resources or functionalities. To use Spring Security, simply add the spring-boot-starter-security dependency to your project.

Once enabled, Spring Security intercepts client requests using a chain of filters. Some of these filters delegate authentication tasks to an AuthenticationManager. If authentication is successful, user details are stored in the SecurityContext, making them available throughout the lifecycle of the request.

Filter chain

The Spring Security filter chain processes incoming HTTP requests and enforces security controls such as authentication and authorization. It consists of a series of filters executed in a specific order, from top to bottom. You can rely on the default filters or customize the chain by adding or modifying filters as needed.

The order of filters is crucial — it determines how requests are processed and how access is granted or denied. Filters are usually arranged from less strict to more strict, to avoid processing errors or unintended behavior.

There are several default security filters. These filters can be customized, replaced, or disabled. Here are the most common ones:

  • UsernamePasswordAuthenticationFilter is a filter that performs user authentication based on the username and password passed as parameters in the request and then calls the AuthenticationManager to authenticate the user.

  • BasicAuthenticationFilter is a filter that looks for the standard authorization header ("Authorization") and then calls the AuthenticationManager to authenticate the user.

  • SecurityContextPersistenceFilter populates SecurityContext based on HttpSession, or creates a new one if it doesn't exist.

  • LogoutFilter is a filter that logs out the user, invalidating the session and deleting the authentication information.

  • ExceptionTranslationFilter: Catches security exceptions and returns appropriate HTTP responses (e.g., redirects or 401s).

  • AuthorizationFilter: Restricts URL access using AuthorizationManager; last in the filter chain.

  • RememberMeAuthenticationFilter: Enables "remember me" login via tokens or cookies; not included by default.

  • CsrfFilter: Protects against CSRF attacks by validating CSRF tokens.

Authentication configuration

Authentication is the process of verifying a user's identity before granting access to the application. By default, adding the Spring Security starter dependency automatically enables authentication and creates a default user. However, in most cases, we need to configure custom users.

To configure authentication in Spring Security 6.1.0, follow these steps:

  • Create a Security Configuration Class

    Start by creating a configuration class and defining a UserDetailsService bean. This bean provides in-memory user details using the built-in User class and InMemoryUserDetailsManager.

    @Configuration
    class SecurityConfig {
        @Bean
        fun userDetailsService(): UserDetailsService {
            val user1 = User.withUsername("user1")
                .password(passwordEncoder().encode("pass1"))
                .roles()
                .build()
    
            val user2 = User.withDefaultPasswordEncoder()
                .username("user2")
                .password("pass2")
                .roles()
                .build()
    
            return InMemoryUserDetailsManager(user1, user2)
        }
    
        @Bean
        fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()
    }
    @Configuration
    public class SecurityConfig {
        @Bean
        public UserDetailsService userDetailsService() {
            UserDetails user1 = User.withUsername("user1")
                    .password(passwordEncoder().encode("pass1"))
                    .roles()
                    .build();
    
            UserDetails user2 = User.withDefaultPasswordEncoder()
                    .username("user2")
                    .password("pass2")
                    .roles()
                    .build();
    
            return new InMemoryUserDetailsManager(user1, user2);
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return PasswordEncoderFactories.createDelegatingPasswordEncoder();
        }
    }
    • User.withUsername and User.withDefaultPasswordEncoder are used to define users.

    • InMemoryUserDetailsManager stores user data in memory.

    • A PasswordEncoder is required to encode passwords, even in memory.

    withDefaultPasswordEncoder() is deprecated and not recommended for production use. It should be used only for demo or testing purposes.

  • Understand Password Encoding

    Spring Security enforces password encoding to prevent storing plain text passwords. Implementations of PasswordEncoder (like BCryptPasswordEncoder) provide secure encoding and verification mechanisms.

    • encode(rawPassword) encodes the password for storage.

    • matches(rawPassword, encodedPassword) checks if the password is correct during login.

    You can create a delegating encoder as shown above or use a specific one like BCryptPasswordEncoder.

  • Customize HTTP Security

    You can define a SecurityFilterChain bean to control the authentication mechanisms like form-based login and HTTP basic auth:

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain =
        http
            .authorizeHttpRequests { it.anyRequest().authenticated() }
            .formLogin(Customizer.withDefaults())
            .httpBasic(Customizer.withDefaults())
            .build()
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults())
                .build();
    }
    • authorizeHttpRequests ensures all endpoints require authentication.

    • formLogin and httpBasic enable the respective authentication methods with default settings.

    • Customizations can be added using Customizer lambdas.

    When you define a custom SecurityFilterChain, the default configuration is overridden. Be sure to explicitly enable the desired features.

Authorization configuration

In Spring Security, authorization is about controlling access to resources based on the user's identity. To distinguish users and assign access, Spring provides the concepts of roles and authorities.

  • Authorities are granular permissions like READ, WRITE, DELETE and UPDATE. For example, user "Jessica" might have READ and WRITE, while "Joseph" has all four.

  • Roles are groups of authorities. For example, ROLE_USER may include READ and WRITE, while ROLE_ADMIN may include all four permissions.

In practice, roles and authorities are often interchangeable: roles("ADMIN") is equivalent to authorities("ROLE_ADMIN"). Note: roles(...) auto-adds the ROLE_ prefix; authorities(...) requires you to add it manually.

Let’s look at an example app with these endpoints:

  • GET /, GET /public — accessible to all (unauthenticated users too).

  • GET /secured — for any authenticated user.

  • GET /user — for users with roles USER or ADMIN.

  • GET /admin — for users with the ADMIN role.

  • POST /public — for users with the WRITE authority.

To support this, we’ll create a REST controller:

@RestController
class DemoController {
    @PostMapping("/public") 
    fun postPublic() = "Access to 'POST /public' granted"

    @GetMapping("/public") 
    fun getPublic() = "Access to 'GET /public' granted"

    @GetMapping("/secured") fun secured() = "Access to '/secured' granted"
    
    @GetMapping("/user") 
    fun user() = "Access to '/user' granted"
    
    @GetMapping("/admin") 
    fun admin() = "Access to '/admin' granted"
}
@RestController
public class DemoController {
    @PostMapping("/public")
    public String postPublic() {
        return "Access to 'POST /public' granted";
    }

    @GetMapping("/public")
    public String getPublic() {
        return "Access to 'GET /public' granted";
    }

    @GetMapping("/secured")
    public String secured() {
        return "Access to '/secured' granted";
    }

    @GetMapping("/user")
    public String user() {
        return "Access to '/user' granted";
    }

    @GetMapping("/admin")
    public String admin() {
        return "Access to '/admin' granted";
    }
}

And we'll define three users in memory:

@Configuration
class SecurityConfig {
    @Bean
    fun userDetailsService(): UserDetailsService {
        val user1 = User.withUsername("user1")
            .password(passwordEncoder().encode("pass1"))
            .authorities("WRITE")
            .build()
        val user2 = User.withUsername("user2")
            .password(passwordEncoder().encode("pass2"))
            .roles("USER")
            .build()
        val user3 = User.withUsername("user3")
            .password(passwordEncoder().encode("pass3"))
            .authorities("ROLE_ADMIN", "WRITE")
            .build()
        return InMemoryUserDetailsManager(user1, user2, user3)
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}
@Configuration
public class SecurityConfig {
    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user1 = User.withUsername("user1")
                .password(passwordEncoder().encode("pass1"))
                .authorities("WRITE")
                .build();
        UserDetails user2 = User.withUsername("user2")
                .password(passwordEncoder().encode("pass2"))
                .roles("USER")
                .build();
        UserDetails user3 = User.withUsername("user3")
                .password(passwordEncoder().encode("pass3"))
                .authorities("ROLE_ADMIN", "WRITE")
                .build();
        return new InMemoryUserDetailsManager(user1, user2, user3);
    }

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

Remember: authorities are case-sensitive, and when using authorities(), you must include ROLE_ if assigning a role.

Now let’s configure access to endpoints using the SecurityFilterChain:

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain =
    http
        .authorizeHttpRequests { auth ->
            auth
                .requestMatchers("/user").hasAnyRole("USER", "ADMIN")
                .requestMatchers("/admin").hasRole("ADMIN")
                .requestMatchers(HttpMethod.POST, "/public").hasAuthority("WRITE")
                .requestMatchers("/secured").authenticated()
                .requestMatchers(HttpMethod.GET, "/*").permitAll()
                .anyRequest().denyAll()
        }
        .httpBasic(Customizer.withDefaults())
        .csrf { it.disable() }
        .build()
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/user").hasAnyRole("USER", "ADMIN")
                    .requestMatchers("/admin").hasRole("ADMIN")
                    .requestMatchers(HttpMethod.POST, "/public").hasAuthority("WRITE")
                    .requestMatchers("/secured").authenticated()
                    .requestMatchers(HttpMethod.GET, "/*").permitAll()
                    .anyRequest().denyAll()
            )
            .httpBasic(Customizer.withDefaults())
            .csrf(csrf -> csrf.disable())
            .build();
}

Explanation:

  • hasRole("ADMIN") → only users with ROLE_ADMIN can access /admin.

  • hasAuthority("WRITE") → only users with this authority can POST /public.

  • authenticated() → any logged-in user can access /secured.

  • permitAll() → allows public GET access to / and /public.

  • denyAll() → blocks all other undefined requests.

  • Order matters: specific matchers must come before more general ones to avoid incorrect access.

With this setup, your Spring Security configuration supports roles, authorities, and endpoint-specific authorization with HTTP Basic Auth.

Method-level authorization

In Spring Security, endpoint-level authorization controls access based on URL patterns. But for fine-grained control — like restricting specific service methods — method-level authorization is a better choice. Spring supports this through annotations such as @PreAuthorize, @PostAuthorize, @PreFilter, and @PostFilter.

To enable it, use @EnableMethodSecurity in your security configuration:

@Configuration
@EnableMethodSecurity
class SecurityConfig {
    @Bean
    fun userDetailsService(): UserDetailsService =
        InMemoryUserDetailsManager(
            User.withUsername("john").password("{noop}123").authorities("read").build(),
            User.withUsername("jane").password("{noop}123").authorities("write").build()
        )
}
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
    @Bean
    public UserDetailsService userDetailsService() {
        return new InMemoryUserDetailsManager(
                User.withUsername("john").password("{noop}123").authorities("read").build(),
                User.withUsername("jane").password("{noop}123").authorities("write").build()
        );
    }
}

@PreAuthorize

Evaluated before the method runs. The method executes only if the condition is true.

@GetMapping("/hello")
@PreAuthorize("hasAuthority('read')")
fun hello(): String = "Hello!"

@GetMapping("/profile/{name}")
@PreAuthorize("authentication.name == #name")
fun profile(@PathVariable name: String): String = "You are $name"
@GetMapping("/hello")
@PreAuthorize("hasAuthority('read')")
public String hello() {
    return "Hello!";
}

@GetMapping("/profile/{name}")
@PreAuthorize("authentication.name == #name")
public String profile(@PathVariable String name) {
    return "You are " + name;
}

@PostAuthorize

Evaluated after the method returns. Useful when access depends on the result.

@GetMapping("/details")
@PostAuthorize("@dm.decide(returnObject)")
fun getName(): UserDetails? =
    SecurityContextHolder.getContext().authentication.principal as? UserDetails

@Component("dm")
class DecisionMaker {
    fun decide(userDetails: UserDetails?): Boolean =
        userDetails?.username?.startsWith("jo") == true
}
@GetMapping("/details")
@PostAuthorize("@dm.decide(returnObject)")
public UserDetails getName() {
    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    if (principal instanceof UserDetails) {
        return (UserDetails) principal;
    }
    return null;
}

@Component("dm")
class DecisionMaker {
    public boolean decide(UserDetails userDetails) {
        return userDetails != null && userDetails.getUsername().startsWith("jo");
    }
}

@PreFilter

Filters input collections before method execution.

@Service
class DemoService {
    @PreFilter(filterTarget = "names", value = "filterObject != authentication.name")
    fun process(names: List<String>): List<String> =
        names.map { it.uppercase() }
}
@Service
public class DemoService {
    @PreFilter(filterTarget = "names", value = "filterObject != authentication.name")
    public List<String> process(List<String> names) {
        return names.stream()
                .map(String::toUpperCase)
                .collect(Collectors.toList());
    }
}

@PostFilter

Filters return values after method execution.

@PostFilter("filterObject == authentication.name")
fun getNames(): List<String> = listOf("john", "jane", "bob")
@PostFilter("filterObject == authentication.name")
public List<String> getNames() {
    return List.of("john", "jane", "bob");
}

These annotations support SpEL (Spring Expression Language) to create dynamic rules like:

  • @PreAuthorize("hasRole('ADMIN')")

  • @PreAuthorize("#username == authentication.name")

Crypto

Spring Security’s Crypto module provides support for encryption, decryption, and password hashing. It is included by default with spring-boot-starter-security. If you're not using Spring Boot, you can include the spring-security-crypto dependency manually.

  • Encryption protects sensitive data (e.g., credit card numbers) and requires a key to both encrypt and decrypt. Symmetric encryption (like AES) uses the same key for both. Store keys securely and comply with standards like PCI DSS.

  • Hashing is used for passwords. Hashed passwords can't be reversed — users must reset them if forgotten. To defend against brute force and rainbow table attacks, use strong hashing algorithms (e.g., BCrypt) and salt (a unique, random string added to each password before hashing).

Spring Crypto provides two main interfaces for encryption: BytesEncryptor and TextEncryptor. The former works with byte arrays, while the latter is used for strings. You can create implementations of both using static methods from the Encryptors class. Here’s how to declare a BytesEncryptor bean:

@Bean
open fun aesBytesEncryptor(): BytesEncryptor {
    val password = "hackme" // should be kept in a secure place and not be shared
    val salt = "8560b4f4b3" // should be hex-encoded with even number of chars
    return Encryptors.standard(password, salt)
}
@Bean
public BytesEncryptor aesBytesEncryptor() {
    String password = "hackme"; // should be kept in a secure place and not be shared
    String salt = "8560b4f4b3"; // should be hex-encoded with even number of chars
    return Encryptors.standard(password, salt);
}

This encryptor uses a 256-bit AES symmetric encryption algorithm — one of the most secure standards approved for top-secret information.

Let’s see how it works:

val inputData = byteArrayOf(104, 121, 112, 101, 114, 115, 107, 105, 108, 108)
val encryptedData = bytesEncryptor.encrypt(inputData)
val decryptedData = bytesEncryptor.decrypt(encryptedData)

println("Input data: $inputData")
println("Encrypted data: $encryptedData")
println("Decrypted data: $decryptedData")
byte[] inputData = new byte[]{104, 121, 112, 101, 114, 115, 107, 105, 108, 108};
byte[] encryptedData = bytesEncryptor.encrypt(inputData);
byte[] decryptedData = bytesEncryptor.decrypt(encryptedData);

System.out.printf("Input data: %s%n", Arrays.toString(inputData));
System.out.printf("Encrypted data: %s%n", Arrays.toString(encryptedData));
System.out.printf("Decrypted data: %s%n", Arrays.toString(decryptedData));

If your data is in string format, it’s better to use TextEncryptor. There are two implementations: NoOpTextEncryptor, which does nothing and should be avoided, and HexEncodingTextEncryptor, which wraps BytesEncryptor and produces hex-encoded output that’s easier to store.

@Bean
open fun hexEncodingTextEncryptor(): TextEncryptor {
    val password = "hackme"
    val salt = "8560b4f4b3"
    return Encryptors.text(password, salt)
}
@Bean
public TextEncryptor hexEncodingTextEncryptor() {
    String password = "hackme";
    String salt = "8560b4f4b3";
    return Encryptors.text(password, salt);
}

Example usage:

val inputData = "hyperskill"
val encryptedData = textEncryptor.encrypt(inputData)
val decryptedData = textEncryptor.decrypt(encryptedData)

println("Input data: $inputData")
println("Encrypted data: $encryptedData")
println("Decrypted data: $decryptedData")
String inputData = "hyperskill";
String encryptedData = textEncryptor.encrypt(inputData);
String decryptedData = textEncryptor.decrypt(encryptedData);

System.out.printf("Input data: %s%n", inputData);
System.out.printf("Encrypted data: %s%n", encryptedData);
System.out.printf("Decrypted data: %s%n", decryptedData);

Instead of hardcoding the salt, you can use Spring’s built-in key generator to create a random one:

val salt = KeyGenerators.string().generateKey()
String salt = KeyGenerators.string().generateKey();

This will generate a hex-encoded string like: 861fddff2d08c730.

To hash passwords securely, use the PasswordEncoder interface. The simplest implementation is NoOpPasswordEncoder, which performs no hashing and should be used only for testing. For production, consider using these secure alternatives: BCryptPasswordEncoder, Pbkdf2PasswordEncoder, or SCryptPasswordEncoder.

Here’s an example with BCryptPasswordEncoder:

val strength = 7
val bCryptEncoder = BCryptPasswordEncoder(strength)

val rawPassword = "hackme"
val firstEncodedPassword = bCryptEncoder.encode(rawPassword)
val secondEncodedPassword = bCryptEncoder.encode(rawPassword)

println("First encoded password: $firstEncodedPassword")
println("Second encoded password: $secondEncodedPassword")
int strength = 7;
PasswordEncoder bCryptEncoder = new BCryptPasswordEncoder(strength);

String rawPassword = "hackme";
String firstEncodedPassword = bCryptEncoder.encode(rawPassword);
String secondEncodedPassword = bCryptEncoder.encode(rawPassword);

System.out.printf("First encoded password: %s%n", firstEncodedPassword);
System.out.printf("Second encoded password: %s%n", secondEncodedPassword);

Each hash will be different even for the same input, since BCrypt includes its own random salt internally. The strength parameter controls the computational cost — increasing it makes brute-force attacks harder, but also slows down processing.

Another option is DelegatingPasswordEncoder, which acts as a wrapper around multiple encoding algorithms. It stores the encoding algorithm along with the hash using a format like {id}encodedPassword. This allows you to change hashing algorithms in the future without breaking backward compatibility.

Accessing authenticated user information

After a user logs in, Spring Security creates an Authentication object that holds their details. You can use it to retrieve:

  • getName() — returns the username.

  • getAuthorities() — returns user roles/authorities.

  • getPrincipal() — returns the user object (typically a UserDetails implementation), which you may need to cast.

Spring Security lets you access the Authentication or Principal directly as controller method arguments:

@GetMapping("/username")
fun username(auth: Authentication) = println(auth.name)
@GetMapping("/username")
public void username(Authentication auth) {
    System.out.println(auth.getName());
}

To get full user details:

@GetMapping("/details")
fun details(auth: Authentication) {
    val user = auth.principal as UserDetails
    println("Username: ${user.username}")
    println("Authorities: ${user.authorities}")
}
@GetMapping("/details")
public void details(Authentication auth) {
    UserDetails user = (UserDetails) auth.getPrincipal();
    System.out.printf("Username: %s%n", user.getUsername());
    System.out.printf("Authorities: %s%n", user.getAuthorities());
}

Using @AuthenticationPrincipal, you can inject the current UserDetails directly:

@GetMapping("/username")
fun username(@AuthenticationPrincipal user: UserDetails) = println(user.username)
@GetMapping("/username")
public void username(@AuthenticationPrincipal UserDetails user) {
    System.out.println(user.getUsername());
}

This approach avoids casting and is more concise.

To access user details outside controller methods (e.g., in services), use SecurityContextHolder:

val auth = SecurityContextHolder.getContext().authentication
Authentication auth = SecurityContextHolder.getContext().getAuthentication();

This method is harder to test and should be used sparingly.

For example, consider a simple item tracker application, where users can add and retrieve items they have created. Each user's items are stored under their username. Only the items they created are returned.

@RestController
class ItemsController {
    private val items = ConcurrentHashMap<String, MutableSet<String>>()

    @PostMapping("/items")
    fun addItem(@AuthenticationPrincipal user: UserDetails, @RequestParam item: String) {
        items.computeIfAbsent(user.username) { HashSet() }.add(item)
    }

    @GetMapping("/items")
    fun getItems(@AuthenticationPrincipal user: UserDetails): Set<String> =
        items.getOrDefault(user.username, setOf())
}
@RestController
public class ItemsController {
    private final Map<String, Set<String>> items = new ConcurrentHashMap<>();

    @PostMapping("/items")
    public void addItem(@AuthenticationPrincipal UserDetails user, @RequestParam String item) {
        items.computeIfAbsent(user.getUsername(), k -> new HashSet<>()).add(item);
    }

    @GetMapping("/items")
    public Set<String> getItems(@AuthenticationPrincipal UserDetails user) {
        return items.getOrDefault(user.getUsername(), Collections.emptySet());
    }
}

Final thoughts

In interviews, you'll be expected to explain how Spring handles web requests, how you manage input validation and errors, and how to implement secure authentication and authorization. Practicing secure REST API development in Spring Boot will help you demonstrate your readiness for real-world backend roles.

You don't need to memorize every annotation or class. It's better to focus on understanding how Spring Web and Spring Security work, and be able to explain the request lifecycle, security filters, and typical design patterns used in real-world projects.

4 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo