· Jose Antonio López  · 19 min read

Authentication in Spring Security with JWT - Complete Guide

Step-by-step implementation and best practices to secure REST APIs in Java with JSON Web Token, persistence in PostgreSQL, and effective management of roles and permissions.

Step-by-step implementation and best practices to secure REST APIs in Java with JSON Web Token, persistence in PostgreSQL, and effective management of roles and permissions.

Objective

A real guide on how to configure a Spring Boot 3.4.1 project with Spring Security 6.4.2:

FeatureDescription
Spring SecuritySecurity for the application using Spring Security.
JWT in a header called Set-CookieUsing JWT for authentication
Users in databaseUsers stored in a PostgreSQL database

Why am I writing this article?

There are many articles, tutorials, and even courses that are unclear about implementing JWT authentication in Spring.

Much of the content shows the way but does not reference the official documentation and is far from real code.

Details are lost and important aspects such as saving users in the database are not covered in depth.

Prerequisites

To follow this article, you need to have installed:

Tool/StepDownload link
JavaDownload Java
MavenDownload Maven
Docker DesktopDownload Docker Desktop
IntelliJ IDEA CommunityDownload IntelliJ IDEA Community
Project with Spring Boot 3.4.1Create Spring Boot project
Install PostgreSQL databasePostgreSQL installation guide

Dependencies

The dependencies used are:

DependencyDescription
spring-boot-starter-data-jpaJava Persistence API support for interacting with databases. More in the official documentation.
spring-boot-starter-securitySecurity support for the application. More in the official documentation.
spring-boot-starter-oauth2-authorization-serverProvides an OAuth2 authorization server to manage access tokens. More in the official documentation.
spring-boot-starter-validationData validation support using the Bean Validation specification. More in the official documentation.
spring-boot-starter-webDependencies needed to build web applications with servlet. More in the official documentation.
spring-boot-docker-composeFacilitates integration with Docker Compose to manage Docker containers. More detail in this blog guide
postgresqlJDBC driver to connect and work with PostgreSQL databases. It’s quite dense, but More in the official documentation.
spring-boot-devtoolsDevelopment tools that allow incremental builds during development. More detail in this blog guide
lombokLibrary that reduces boilerplate code through annotations. More in the official documentation.

I chose Maven as the dependency manager because I feel more comfortable with it. If you prefer Gradle, you can use it without any problem.

Design Decisions

Onion Architecture

The architecture will be onion architecture. It allows responsibilities to be separated into three main layers:

LayerDescription
DomainEntities, service interfaces, and domain exceptions.
ApplicationImplementation of application services, mappers, and DTOs.
InfrastructureControllers, filters, application configuration, JPA repository interface, and repository implementation.

I chose this architecture because I think it’s a good way to organize code. It’s a stepping stone for programmers who have traditionally worked with layered architectures.

Moving to a hexagonal or clean architecture can be a very abrupt change.

Below you can see the structure

auth
├── application
│   ├── AuthCookieConstants.java
│   ├── mappers
│   │   └── AuthMapper.java
│   └── services
│       ├── AuthServiceImpl.java
│       └── TokenServiceImpl.java
├── domain
│   ├── AuthException.java
│   ├── Role.java
│   ├── services
│   │   ├── AuthService.java
│   │   └── TokenService.java
│   ├── User.java
│   └── UserRepository.java
└── infrastructure
    ├── config
    │   ├── EncoderConfig.java
    │   └── SecurityConfig.java
    ├── controllers
    │   └── AuthController.java
    ├── dtos
    │   ├── CreateUserDto.java
    │   ├── LoginRequestDTO.java
    │   └── UserResponseDTO.java
    ├── filters
    │   ├── JwtAuthenticationFilter.java
    └── persistance
        └── PostgresUserRepository.java

Dependency Injection via Constructor

The type of dependency injection I used is via constructor. It’s the best way to inject dependencies in Spring and the recommended way in the official documentation.

If you usually use field injection, I recommend switching to constructor injection.

Lombok

To reduce boilerplate code, I used the Lombok library, which reduces visible code through annotations. In this case, annotations like @Getter, @Setter, @NoArgsConstructor, @AllArgsConstructor, and @Builder are used.

Other annotations that can be used are @Data, @EqualsAndHashCode, and @ToString, although I consider these touch on sensitive topics and recommend implementing them manually if needed.

Domain Entities Annotated with JPA

Domain entities are classes that represent things from the problem we’re solving. In this case, an entity would be User.

These classes are annotated with @Entity and @Table to indicate they are also JPA entities.

Annotating domain entities with framework annotations can be considered bad practice. The technical term is domain contamination. I decided to do it to simplify the code and avoid creating unnecessary mappers that add complexity.

Authentication will be done with JWT and sent in a header called Set-Cookie. The main reasons are that the API will be consumed from a web client and it’s safer to send the token in a cookie with the following characteristics:

  • HttpOnly: The cookie is not accessible from JavaScript.
  • Secure: The cookie is only sent over HTTPS.
  • SameSite: The cookie is not sent in third-party requests.
  • Max-Age: The cookie expires in X minutes.

If you want to dive deeper, I recommend reading the documentation on mozilla.org.

WebSecurityConfig Class Configuration

The WebSecurityConfig class is the main configuration for Spring Security. It configures the application’s security and defines which routes are public and which are not.

Here’s a link to the guide to implement and understand it on this same blog.

Users in Database

User Entity

The first step is to define the User entity that represents a user. The User entity implements the UserDetails interface from Spring Security.

The interface defines some default methods that are already implemented.

Personally, I don’t like having default implementations in an interface, but that’s how it is.

User.java
package com.auth.domain;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "users")
public class User implements UserDetails {
    @Id
    @UuidGenerator(style = UuidGenerator.Style.AUTO)
    private UUID id;
    private String username;
    private String password;
    private String firstName;
    private String surnames;
    @Column(unique = true)
    private String email;
    private String phoneNumber;
    @Column(name = "role", nullable = true)
    @Enumerated(EnumType.STRING)
    private Role role;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(role);
    }

    @Override
    public boolean isAccountNonExpired() {
        return UserDetails.super.isAccountNonExpired();
    }

    @Override
    public boolean isAccountNonLocked() {
        return UserDetails.super.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return UserDetails.super.isCredentialsNonExpired();
    }

    @Override
    public boolean isEnabled() {
        return UserDetails.super.isEnabled();
    }
}

Why UserDetails?

UserDetails is an interface that represents a user in Spring Security and is used by a service called UserDetailsService.

This is necessary so that Spring Security can authenticate a user without additional code.

Explanation of Annotations

@Entity: Indicates that the class is a JPA entity. More information in the official documentation.

@Table(name = "users"): Specifies that the entity will be stored in a table named users. If not specified, JPA uses the class name as the table name.

@Id: Marks the id field as the primary key of the entity.

@UuidGenerator: Automatically generates a UUID. By default, Hibernate generates version 4 UUIDs.

@Column: Indicates that the email field is unique in the database. To keep the code concise, only essential fields are annotated with @Column.

@Enumerated(EnumType.STRING): Specifies that the role field is an Enum and will be stored as a String in the database.

User Repository in Domain

First, you need to define a repository interface in the domain. This controls the operations that make sense for the problem being solved.

UserRepository.java
package com.auth.domain;

import java.util.Optional;
import java.util.UUID;

public interface UserRepository {
    Optional<User> findByEmail(String email);
    User save(User user);
    Optional<User> findById(UUID id);
}

Additional Information

In the context of Domain-Driven Design (DDD) or patterns like Repository Pattern, a repository acts as an abstraction over the data persistence layer.

User Repository in Infrastructure

You also need to define a repository in the infrastructure. Do not confuse it with the domain repository.

UserRepository.java
package com.auth.infrastructure.persistance;

@Repository
public interface PostgresUserRepository extends JpaRepository<User, UUID>, UserRepository {
}

Additional Information

The infrastructure acts on the database and allows the domain to remain independent of implementation details.

@Repository: Indicates that the class is a Spring component and will be used to access the database. Mentioned in passing in the official documentation.

PostgresUserRepository: Clearly indicates that it serves for the implementation of a PostgreSQL database.

JPARepository: Provides methods to interact with the database. Remember to pass the primary key data type as the ID. More information in the official documentation.

Tip

You can tell that the interface should go in infrastructure because it’s annotated with @Repository. You should never use Spring annotations in the domain. An exception is annotating entities with @Entity and always with proper justification.

DTOs

DTOs (Data Transfer Objects) are objects used to transfer data between layers. In this case, they are used to transfer data between the infrastructure layer and the application layer.

CreateUserDto

To create a user, a DTO is defined containing the necessary fields. You can add or remove fields according to your application’s needs. For this example, the fields email, firstName, and password are used.

CreateUserDto.java
package com.auth.infrastructure.dtos;

public record CreateUserDto(
  @Email
  @NotBlank
  String email,

  @NotBlank
  String firstName,

  @NotBlank
  String password
) {
}

Annotation Explanation

@NotBlank: Indicates that the field cannot be empty. @Email: Indicates that the field must be a valid email. It might not be perfect, but it’s a good start.

Tip

A DTO is an object with immutable information and should never be modified. For DTOs, I always recommend using record introduced in Java 14. Use it in Java 17 or higher, where it became stable.

LoginRequestDTO

The following DTO is for login and contains the necessary fields to authenticate a user.

LoginRequestDTO.java
package com.auth.infrastructure.dtos;
public record LoginRequestDTO(
  @Email
  @NotBlank
  String email,

  @NotBlank
  String password
) {
}

UserResponseDTO

The last DTO is used to return user data.

UserResponseDTO.java
package com.auth.infrastructure.dtos;
public record UserResponseDTO(
  UUID id,
  String name,
  String email,
  Role role
) {
}

Very Important

Never return sensitive data such as the user’s password. The UserResponseDTO does not return the user’s password.

AuthMapper

The AuthMapper is a class that transforms DTOs to domain entities or vice versa. It’s good practice to have a mapper dedicated solely to transforming data between layers.

AuthMapper.java
package com.auth.application.mappers;

public class AuthMapper {
  private AuthMapper() {
    throw new UnsupportedOperationException("This class should never be instantiated");
  }

  public static User fromDto(final CreateUserDto createUserDto) {
    return User.builder()
      .email(createUserDto.email())
      .firstName(createUserDto.firstName())
      .build();
  }

  public static Authentication fromDto(final LoginRequestDTO loginRequestDTO) {
    return new UsernamePasswordAuthenticationToken(loginRequestDTO.email(), loginRequestDTO.password());
  }

  public static UserResponseDTO toDto(final User user) {
    return new UserResponseDTO(user.getId(), user.getFirstName(), user.getEmail(), user.getRole());
  }
}

Tips

For mapping entities, I like to follow these best practices:

  • Do not use mapping libraries: I prefer not to use libraries like MapStruct. I like to have full control and avoid unnecessary dependencies.
  • Purely static class: The AuthMapper class is purely static. It’s a utility class with only static methods.
  • Do not instantiate the class: The AuthMapper class should not be instantiated and throws an exception if instantiation is attempted. I use UnsupportedOperationException to indicate to other developers that the class should not be instantiated.

AuthService

AuthService in Domain

This service is one of the most important and handles logic related to authentication and user management.

AuthService.java

package com.auth.domain.services;

public interface AuthService {
    String login(LoginRequestDTO loginRequestDTO);
    boolean validateToken(String token);
    String getUserFromToken(String token);
    void createUser(CreateUserDto createUserDto);
    User getUser(UUID id);
}

Tip

During development, I was tempted to extend UserDetailsService directly. That wouldn’t be entirely correct because UserDetailsService is a Spring Security interface and shouldn’t be used in the domain layer.

AuthService Implementation in Application

AuthServiceImpl.java
package com.auth.application.services;

@Service
public class AuthServiceImpl implements AuthService, UserDetailsService {
    private static final Logger logger = LogManager.getLogger(AuthServiceImpl.class);

    private final UserRepository userRepository;
    private final TokenService tokenService;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationConfiguration authenticationConfiguration;


    public AuthServiceImpl(
        UserRepository userRepository,
        TokenService tokenService,
        PasswordEncoder passwordEncoder,
        AuthenticationConfiguration authenticationConfiguration
    ) {
        this.userRepository = userRepository;
        this.tokenService = tokenService;
        this.passwordEncoder = passwordEncoder;
        this.authenticationConfiguration = authenticationConfiguration;
    }

    @Override
    public void createUser(final CreateUserDto createUserDto) {
        final User createUser = AuthMapper.fromDto(createUserDto);
        createUser.setPassword(passwordEncoder.encode(createUserDto.password()));
        final User user = userRepository.save(createUser);
        logger.info("[USER] : User successfully created with id {}", user.getId());
    }


    @Override
    public User getUser(final UUID id) {
        return userRepository.findById(id)
            .orElseThrow(() -> {
                logger.error("[USER] : User not found with id {}", id);
                return new FincasException(FincasErrorMessage.USER_NOT_FOUND);
            });
    }

    @Override
    public String login(final LoginRequestDTO loginRequest) {
        try {
            final AuthenticationManager authenticationManager = authenticationConfiguration.getAuthenticationManager();
            final Authentication authRequest = AuthMapper.fromDto(loginRequest);
            final Authentication authentication = authenticationManager.authenticate(authRequest);
            return tokenService.generateToken(authentication);

        } catch (Exception e) {
            logger.error("[USER] : Error while trying to login", e);
            throw new ProviderNotFoundException("Error while trying to login");
        }
    }


    @Override
    public boolean validateToken(final String token) {
        return tokenService.validateToken(token);
    }

    @Override
    public String getUserFromToken(final String token) {
        return tokenService.getUserFromToken(token);
    }

    @Override
    public UserDetails loadUserByUsername(final String username) {
        return userRepository.findByEmail(username)
            .orElseThrow(() -> {
                logger.error("[USER] : User not found with email {}", username);
                return new UsernameNotFoundException("User not found");
            });
    }
}

Code Explanation

@Service: Indicates that the class is a Spring component and will be used for business logic.

loadUserByUsername: Method from the UserDetailsService interface that Spring Security calls internally.

AuthenticationConfiguration: Class that configures authentication in Spring Security.

Tip

Here it makes sense to implement UserDetailsService. The interface is from Spring Security and the service implementation is from the application. AuthenticationConfiguration helps break a circular dependency that appeared between AuthService and SecurityConfig. The circular dependency appears between AuthenticationManager and AuthService.

Secret Key and Expiration Time

application.yml

We’ll use application.yml with custom properties. Configuring these parameters in a properties file is good practice because they can be changed without recompiling the code.

application.yml
application:
  security:
    jwt:
      secret-key: 9a8b7c6d5e4f3g2h1i0j9k8l7m6n5o4p3q2r1s0t
      expiration: 15 # minutes

Explanation of Properties

secret-key: Secret key to sign the JWT token. It’s important that it’s a secure key and not shared with anyone.

expiration: JWT token expiration time in minutes. It’s usually set in milliseconds, but for simplicity, I set it in minutes.

The expiration time will depend a lot on the application and the security you want to implement. If the expiration time is too short, users will have to log in constantly. If it’s too long, the token can be stolen and used by an attacker. I think 15 minutes is enough for most applications.

EncoderConfig

JWT encoding and decoding is done in Spring Security via Beans. In this case, implementations from Nimbus JOSE + JWT SDK are used.

EncoderConfig.java
@Configuration
package com.auth.infrastructure.config;

public class EncoderConfig {
    @Value("${application.security.jwt.secret-key}")
    private String jwtKey;

    @Bean
    JwtEncoder jwtEncoder() {
        return new NimbusJwtEncoder(new ImmutableSecret<>(jwtKey.getBytes()));
    }

    @Bean
    JwtDecoder jwtDecoder() {
        byte[] bytes = jwtKey.getBytes();
        SecretKeySpec originalKey = new SecretKeySpec(bytes,0,bytes.length,"HmacSHA256");
        return NimbusJwtDecoder.withSecretKey(originalKey).macAlgorithm(MacAlgorithm.HS256).build();
    }

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

What Does Nimbus JOSE + JWT SDK Do?

Spring Security provides the JwtEncoder and JwtDecoder interfaces to encode and decode JWTs. They are functional interfaces with an encode and decode method, respectively.

The implementations provided in Spring Security are NimbusJwtEncoder and NimbusJwtDecoder.

Nimbus JOSE + JWT SDK is a Java library that allows the creation and verification of JSON Web Tokens (JWT) and JSON Web Signature (JWS).

The JwtEncoder is like a machine that does three things:

  1. Takes your information (user data)
  2. Converts it into a special format called JWT (JSON Web Token)
  3. Digitally “signs” it using a secret key (provided by the JWKSource)

The JWS Compact Serialization format is the way the information is organized so all systems can understand and verify the tokens.

The JWKSource part is the key keeper. It provides the secret key to “seal” (sign) the token.

What Does BCryptPasswordEncoder Do?

BCryptPasswordEncoder is an implementation of PasswordEncoder that uses the BCrypt encryption algorithm. Under the hood, it uses a hash function. Hash functions take an input and return an output. They are one-way functions, meaning there’s no way to discover the original password from the output.

TokenService in Domain

The TokenService is the service responsible for generating and validating JWT tokens.

TokenService.java
package com.hey.auth.domain.services;

import org.springframework.security.core.Authentication;

public interface TokenService {
    String generateToken(Authentication authentication);
    String getUserFromToken(String token);
    boolean validateToken(String token);
}

TokenService in Domain or Application?

I decided to put it in the domain layer. In my view, the application is a REST API and the token is part of the domain. Security and the future application would coexist in the same project. If security were in a microservice, the project would move as is by moving the auth folder to the new microservice.

TokenService Implementation in Application

The implementation of TokenService is responsible for generating and validating JWT tokens. In this article, the Nimbus JOSE + JWT SDK library is used.

TokenServiceImpl.java
package com.auth.application.services;

@Service
public class TokenServiceImpl implements TokenService {
    private final static Logger logger = LogManager.getLogger(TokenServiceImpl.class);
    @Value("${application.security.jwt.secret-key}")
    private String secretKey;

    @Value("${application.security.jwt.expiration}")
    private int jwtExpiration;

    private final JwtEncoder jwtEncoder;
    private final JwtDecoder jwtDecoder;

    public TokenServiceImpl(JwtEncoder jwtEncoder, JwtDecoder jwtDecoder) {
        this.jwtEncoder = jwtEncoder;
        this.jwtDecoder = jwtDecoder;
    }

    @Override
    public String generateToken(Authentication authentication) {
        Instant now = Instant.now();
        String scope = authentication.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.joining(" "));

        User currentUser = (User) authentication.getPrincipal();
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .subject(currentUser.getEmail())
            .issuedAt(now)
            .expiresAt(now.plus(jwtExpiration, ChronoUnit.MINUTES))
            .build();

        var jwtEncoderParameters = JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims);
        return this.jwtEncoder.encode(jwtEncoderParameters).getTokenValue();
    }

    @Override
    public String getUserFromToken(String token) {
        Jwt jwtToken = jwtDecoder.decode(token);
        return jwtToken.getSubject();
    }

    @Override
    public boolean validateToken(String token) {
        try {
            jwtDecoder.decode(token);
            return true;
        } catch (Exception exception) {
            logger.error("[USER] : Error while trying to validate token", exception);
            throw new BadJwtException("Error while trying to validate token");
        }
    }
}

Additional Information

Code

  • @Value: Indicates that the property will be injected from the properties file.
  • JwtClaimsSet: Class representing the claims of the JWT token.
  • JwtEncoderParameters: Class representing the parameters of the JWT token. It stores the signing algorithm and claims.
  • JwtClaimsSet.Builder: Class that allows building the JWT token. You can add claims and set the expiration date.

Claims

Claims are the information stored in the token. In this case, the user’s email and expiration date are stored. The scope claim is a claim you can use to store information about permissions. In this case, I don’t use it for anything, but it’s a good example of how to retrieve permissions. Later, you can use the scope claim to store information about permissions.

AuthService

Initially, the AuthService had all the logic for generating and validating tokens. I decided to move it to its own service to separate responsibilities. Anyway, it’s valid for AuthService to have token generation and validation logic. Personally, I prefer TokenService to handle token generation and validation, and AuthService to authenticate and manage users.

JWTAuthenticationFilter

The JWTAuthenticationFilter is the filter responsible for validating that the JWT token is valid and that the user has permission to access the route.

JwtAuthenticationFilter.java
package com.auth.infrastructure.filters;

public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final AuthService authService;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(AuthService authService, UserDetailsService userDetailsService) {
        this.authService = authService;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        final String requestURI = request.getRequestURI();
        return requestURI.equals(SecurityConfig.LOGIN_URL_MATCHER);
    }

    @Override
    protected void doFilterInternal(@NotNull HttpServletRequest request,
                                    @NotNull HttpServletResponse response,
                                    @NotNull FilterChain filterChain) throws ServletException, IOException {

        final Optional<String> token = getJwtFromCookie(request);

        if (token.isEmpty() || !authService.validateToken(token.get())) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            throw new BadCredentialsException("Invalid token");
        }

        String userName = authService.getUserFromToken(token.get());
        UserDetails userDetails = userDetailsService.loadUserByUsername(userName);
        UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

        authenticationToken.setDetails(userDetails);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        filterChain.doFilter(request, response);
    }

    private Optional<String> getJwtFromCookie(HttpServletRequest request) {
        final Cookie[] cookies = request.getCookies();
        if (cookies == null || ArrayUtils.isEmpty(cookies)) {
            return Optional.empty();
        }
        return (Arrays.stream(cookies)
            .filter(cookie -> cookie.getName().equals(AuthCookieConstants.TOKEN_COOKIE_NAME))
            .map(Cookie::getValue)
            .findFirst());
    }
}

Additional Information

Code

  • OncePerRequestFilter: Spring Security recommends extending OncePerRequestFilter to create custom filters. This filter runs once per request. You can read more in the official documentation.

  • shouldNotFilter: The filter does not run if the route is login. During login, there is no token, so it shouldn’t trigger.

  • doFilterInternal: The token is validated and the values extracted from the token are passed to the Spring Security context.

  • HttpServletRequest: Represents the incoming HTTP request.

  • HttpServletResponse: Represents the outgoing HTTP response. A 401 error is returned if the token is invalid.

  • FilterChain: Used to pass the request to the next filter.

  • getJwtFromCookie: Retrieves the token from the Set-Cookie header. If there is no token, an empty Optional is returned.

  • UsernamePasswordAuthenticationToken: Represents an authentication token. Stores the user and the user’s permissions.

  • BadCredentialsException: Specific Spring Security exception for when the token is invalid.

Tip

The JwtAuthenticationFilter runs before the controller handles the request. If the token is invalid, the controller does not run and a 401 error is returned. This is a clean way to handle security in the application.

In other frameworks like NestJS, this part is usually handled with an interceptor inside the middleware. In Spring Security, it’s done with a jakarta.servlet.Filter, which is part of the Servlet API.

It’s also important not to declare the filter as a @Component. The filter may be run by both the Spring container and Spring Security. Declare it without the @Component or @Bean annotation. You can find more information in the official documentation.

AuthController

The AuthController manages requests related to user login and logout. In the future, user management can be added, but for now, only login and logout will be managed.

AuthController.java
package com.auth.infrastructure.controllers;
@RestController
@RequestMapping(ApiConfig.API_BASE_PATH + "/auth")
public class AuthController {
    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping
    public void createUser(@RequestBody @Valid CreateUserDto createUserDto) {
        authService.createUser(createUserDto);
    }

    @PostMapping("/login")
    public void login(@RequestBody @Valid LoginRequestDTO loginRequestDTO, HttpServletResponse response) {
        final String token = authService.login(loginRequestDTO);
        final Cookie cookie = createAuthCookie(token);
        response.addCookie(cookie);
    }

    @PostMapping("/logout")
    public void logout(HttpServletResponse response) {
        final Cookie cookie = new Cookie(AuthConstants.TOKEN_COOKIE_NAME, StringUtils.EMPTY);
        cookie.setMaxAge(0);
    }

    private Cookie createAuthCookie(String token) {
        final String SAME_SITE_KEY = "SameSite";
        final Cookie cookie = new Cookie(AuthConstants.TOKEN_COOKIE_NAME, token);
        cookie.setHttpOnly(AuthConstants.HTTP_ONLY);
        cookie.setSecure(AuthConstants.COOKIE_SECURE);
        cookie.setMaxAge(AuthConstants.COOKIE_MAX_AGE);
        cookie.setAttribute(SAME_SITE_KEY, AuthConstants.SAME_SITE);
        return cookie;
    }
}

Login Endpoint

The login endpoint simply passes the data from the request body to the AuthService. If the login is successful, the service generates a String called token. To attach the token to the response, a cookie is created using the jakarta.servlet.http.Cookie class.

The token is stored in a cookie called auth-token and sent to the client. The cookie has the following properties:

  • HttpOnly: The cookie is not accessible from JavaScript.
  • Secure: The cookie is only sent over HTTPS.
  • SameSite: The cookie is not sent in third-party requests.
  • Max-Age: The cookie expires in X minutes.

SameSite Strict is the strictest setting and prevents the cookie from being sent in third-party requests. Sending in third-party requests means the cookie is not sent if the request does not go to the same domain.

If you need more flexibility in the type of cookie, you can check the attribute in the MDN documentation.

Logout Endpoint

The logout endpoint deletes the cookie from the client. In Java, the simplest option is:

  • Create a cookie with the same name as the one you want to delete.
  • Set the cookie’s lifetime to 0.
  • Send the cookie to the client.

Setting the value to StringUtils.EMPTY ensures the cookie has no value. Along with maxAge set to 0, the cookie is deleted from the client.

To avoid domain contamination, I created a class AuthConstants that contains the cookie constants.

AuthCookieConstants.java
package com.auth.application.AuthCookieConstants;
public class AuthConstants {
    private AuthConstants() {
      throw new UnsupportedOperationException("This class should never be instantiated");
    }
    public static final String TOKEN_COOKIE_NAME = "auth-token";
    public static final boolean HTTP_ONLY = true;
    public static final boolean COOKIE_SECURE = true;
    public static final int COOKIE_MAX_AGE = 60 * 12; // 12 min
    public static final String SAME_SITE = "Strict";
}

Cleaner Alternative to AuthCookieConstants

If you don’t want to create a purely static class with constants, you can use a properties file. In this case, the properties file would look like:

application.yml
application:
  security:
    cookie:
      name: auth-token
      http-only: true
      secure: true
      max-age: 60 * 12 # 12 min
      same-site: Strict

Later, you can import the properties into the AuthController class and use them directly. This approach is cleaner as it decouples configuration from implementation. I’ll detail this implementation better when I create an exclusive entry for Cookies

References

  • Spring Security
  • Spring Boot
Share: