JWT Authentication in Spring Security
From parent article
Motivation
Upon successful signin, the user receives both an access token and a refresh token. The access token allows the user to access protected resources without being prompted to sign in again. For security, both the access and refresh tokens have expiration dates, with the refresh token’s expiration date being significantly longer than that of the access token. When the access token expires, the user can use the refresh token to obtain a new access token.
What is JWT?
JWT is a self-contained and signed token, meaning that it has the all the information to identify the user, and all you need to verify the token is the secret key and the token itself.
A JSON Web Token (JWT) is composed of 3 parts, separated by dots (.):
Eg.
eyJhbGciOiJIUzUxMiJ9.eyJqdGkiOiI2NmI1YTBmOWQ3NmI4OTdmNWZlNjFlYzYiLCJzdWIiOiJ3YWx0ZXIiLCJpYXQiOjE3MjM5Mzk2MTEsInRva2VuX3R5cGUiOiJBQ0NFU1NfVE9LRU4iLCJleHAiOjE3MjM5NTc2NzF9.oAnyniCt1bWUmPiI7Es15PgzmRW0C2hExl3iETai54ybZz_cvC9yXh1Lwwxdw14zxI3iHmjRSN8xAsXM5ajGEQ
You can decode the token here.
{
"alg": "HS512"
}
2. Payload: Contains the claims or data, such as user information and token expiration time. Eg.
{
"jti": "66b5a0f9d76b897f5fe61ec6",
"sub": "walter",
"iat": 1723939611,
"token_type": "ACCESS_TOKEN",
"exp": 1723957671
}
3. Signature: This is generated by hashing the header and payload together with a secret key. Eg.
HMACSHA512(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
your-256-bit-secret
)
Verification
To verify the token, you need to check:
HashAlgorithm(Header + Payload, SecretKey) == Signature
Why use JWT?
Benefits of JWT over Opaque Tokens like JSESSIONID:
Reference(Opaque) token: Token not contain any verifiable metadata about the user, a recipient must query the authorization server using the token for information about the user. An example is JSESSIONID which is a randomly generated string.
Access Refresh Token Architecture
Access Token: A short-lived token used to authenticate user requests to protected resources or APIs.
Refresh Token: A long-lived token used to obtain a new access token after the current one expires, without requiring the user to log in again.
In this architecture:
Why Use Access and Refresh Token Architecture?
Using access and refresh tokens improves the user experience by reducing the friction of repeated login prompts. Users can continue making requests using the access token until it expires. Once the access token expires, the refresh token can be used to automatically obtain a new access token without requiring any manual action from the user.
Why Not Just Use the Access Token?
The two-token approach (access and refresh tokens) improves security by addressing the risks associated with access tokens. Since the access token is sent over the network with each request to protected resources, it is at a higher risk of being stolen by a malicious third party. To minimize potential damage, the access token is given a shorter expiry period, ensuring that even if it is compromised, the stolen token will only be valid for a short time.
In contrast, the refresh token has a longer lifespan because it is only used to renew access tokens and is not exposed to the same level of risk since it is not transmitted with every request.
JWT Authentication Flow
Discussion
We’ve established that JWT is a self-contained token that can be validated using the secret key and the token itself. So why do we need to store the tokens in a database and compare the stored token with the token in the request?
The reason is to eliminate stale tokens that become invalid due to actions like logging out or updating the user’s authorities. In these cases, we update the token in the database. If we only validated the token by comparing the hashed header and payload with the signature, stale tokens would still appear valid. However, by checking the token against the stored version in the database, we can ensure that only the most current and valid tokens are accepted.
You might then wonder, why not just store opaque tokens, like JSESSIONID, in a distributed cache and use those for validation? The issue with this approach is that if a malicious party floods our system with requests using fake opaque tokens, our database (or distributed cache) could become overwhelmed with validation queries.
In contrast, using JWTs allows us to verify the token’s validity on the server side without querying the database because JWTs are signed with our secret key. This means we can immediately reject invalid tokens without burdening the database.
We are making a tradeoff here, while we eliminate stale tokens by making extra queries to the database to ensure the tokens are up-to-date, the overall number of queries will likely be lower than if we were using opaque tokens, which would require database queries for every invalid token.
JWT Setup
Add the following dependencies to pom.xml
<!-- JWT authentication -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
</dependency>
Add the JwtUtils.java file.
@Component
public class JwtUtils {
public static final ExpirationTime ACCESS_TOKEN_EXPIRATION_TIME = new ExpirationTime(5, 5, 1, 0);
public static final ExpirationTime REFRESH_TOKEN_EXPIRATION_TIME = new ExpirationTime(30, 5, 5, 0);
public static final String TOKEN_TYPE = "token_type";
private final SecretKey accessSigningKey;
private final SecretKey refreshSigningKey;
private final UserService userService;
@Autowired
public JwtUtils(String accessSecretKey, String refreshSecretKey, UserService userService) {
this.accessSigningKey = Keys.hmacShaKeyFor(accessSecretKey.getBytes());
this.refreshSigningKey = Keys.hmacShaKeyFor(refreshSecretKey.getBytes());
this.userService = userService;
}
/**
* Generates a JWT token
*
* @param authentication UsernameEmailPasswordAuthenticationToken
* @param tokenType TokenType
* @return JWT token
*/
public String generateToken(UsernameEmailPasswordAuthenticationToken authentication, TokenType tokenType) {
return generateToken(
authentication.getId(),
authentication.getName(),
tokenType
);
}
/**
* Generates a JWT token
*
* @param id User ID
* @param userName User name
* @param tokenType TokenType
* @return JWT token
*/
public String generateToken(String id, String userName, TokenType tokenType) {
JwtBuilder jwts = Jwts.builder()
.id(id)
.subject(userName)
.issuedAt(new Date(System.currentTimeMillis()));
/**
* If the token type is ACCESS_TOKEN, then set the expiration time to ACCESS_TOKEN_EXPIRATION_TIME and sign with the accessSigningKey
* Otherwise, set the expiration time to REFRESH_TOKEN_EXPIRATION_TIME and sign with the refreshSigningKey
*/
if (tokenType.equals(ACCESS_TOKEN)) {
jwts
.claims(Map.of(TOKEN_TYPE, ACCESS_TOKEN))
.expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION_TIME.toMillis()))
.signWith(accessSigningKey);
} else {
jwts
.claims(Map.of(TOKEN_TYPE, REFRESH_TOKEN))
.expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION_TIME.toMillis()))
.signWith(refreshSigningKey);
}
return jwts.compact();
}
/**
* Validates the JWT token
*
* @param token JWT token
* @param tokenType TokenType
* @return UsernameEmailPasswordAuthenticationToken
*/
public UsernameEmailPasswordAuthenticationToken validateToken(String token, TokenType tokenType) {
/**
* If the token type is ACCESS_TOKEN, then verify the token with the accessSigningKey
* Otherwise, verify the token with the refreshSigningKey because we sign the different token types with different
* signing keys.
*/
Claims jwtPayload = Jwts.parser()
.verifyWith(
tokenType.equals(ACCESS_TOKEN) ? accessSigningKey : refreshSigningKey
)
.build()
.parseSignedClaims(token)
.getPayload();
/**
* The `parseSignedClaims` method is where we validate the jwt token. It checks
* - if encrypt(base64(header) + "." + base64(payload), secretKey) == signature
* - if the token is expired else throw JwtException
*/
/**
* Since we have 2 types of tokens(access, refresh), we want to check if the token type is valid.
* The refresh token is used for the /auth/refresh and /auth/logout endpoint,
* and the access token is used for all other endpoints. If validateToken is called through the /auth/refresh or
* /auth/logout endpoint the parameter TokenType will be REFRESH_TOKEN, and the token type in the payload will be REFRESH_TOKEN
* otherwise, the parameter TokenType will be ACCESS_TOKEN, and the token type in the payload will be ACCESS_TOKEN.
*/
if (!jwtPayload.get(TOKEN_TYPE).equals(tokenType.name())) {
throw new IllegalArgumentException("Invalid JWT token type");
}
// Retrieves the user from the database
User user = userService.getById(jwtPayload.getId()).orElseThrow(
() ->
new IllegalArgumentException("User not found"));
/**
* If stored in database does not equal the token from request, then throw an exception.
* Because
* - During logout we flush both the access and refresh token from database, or when we generate a new access token
* during refresh
* - The token will still be valid if
* we use parseSignedClaims to check if the encrypted header + payload == signature, or if the token is expired.
* - But in terms of our business logic it is invalid, so we need to check if the token from the request is equal
* to the token stored in the database
*/
String StoredToken = tokenType.equals(ACCESS_TOKEN) ? user.getAccessToken() : user.getRefreshToken();
if (StoredToken == null || !StoredToken.equals(token)) {
throw new JwtException("JWT token does not exist");
}
/**
* return verified Authentication object
*/
return new UsernameEmailPasswordAuthenticationToken(jwtPayload.getId(), jwtPayload.getSubject(), user.getAuthorities());
}
}
Add the following model classes.
@Getter
public class ExpirationTime {
// Getters
private final int days;
private final int hours;
private final int minutes;
private final int seconds;
public ExpirationTime(int days, int hours, int minutes, int seconds) {
this.days = days;
this.hours = hours;
this.minutes = minutes;
this.seconds = seconds;
}
public long toSeconds() {
return ((days * 24L + hours) * 60 + minutes) * 60 + seconds;
}
public long toMillis() {
return toSeconds() * 1000;
}
}
@Data
public class SigninRequest {
private String username;
@Email
private String email;
@NotBlank
private String password;
}
@Data
public class AccessTokenResponse {
private String accessToken;
private long expiresIn;
public AccessTokenResponse(String accessToken, long expiresIn) {
this.accessToken = accessToken;
this.expiresIn = expiresIn;
}
}
public enum TokenType {
ACCESS_TOKEN("access_token"),
REFRESH_TOKEN("refresh_token");
private final String value;
TokenType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
Repository Setup
Add the following fields to User.java if you haven’t
private String accessToken;
private String refreshToken;
Add the following methods to CustomUserRepository.java
void updateTokens(String id, String accessToken, String refreshToken);
void updateAccessToken(String id, String accessToken);
void deleteTokens(String id);
@Override
public void updateTokens(String id, String accessToken, String refreshToken) {
Query query = new Query(Criteria.where("id").is(id));
Update update = new Update()
.set("accessToken", accessToken)
.set("refreshToken", refreshToken);
mongoTemplate.updateFirst(query, update, User.class);
}
@Override
public void updateAccessToken(String id, String accessToken) {
Query query = new Query(Criteria.where("id").is(id));
Update update = new Update()
.set("accessToken", accessToken);
mongoTemplate.updateFirst(query, update, User.class);
}
@Override
public void deleteTokens(String id) {
Query query = new Query(Criteria.where("id").is(id));
Update update = new Update()
.unset("accessToken")
.unset("refreshToken");
mongoTemplate.updateFirst(query, update, User.class);
}
Add the following method it UserService.java if you haven’t.
public void updateTokens(String id, String accessToken, String refreshToken) {
userRepository.updateTokens(id, accessToken, refreshToken);
}
public void updateAccessToken(String id, String accessToken) {
userRepository.updateAccessToken(id, accessToken);
}
public void deleteTokens(String id) {
userRepository.deleteTokens(id);
}
Security Filter Chain Setup
Add the file JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION = "Authorization";
private static final String BEARER = "Bearer";
private static final String REFRESH_COOKIE = "refresh-token";
private static final String ACCESS_COOKIE = "access-token";
private static final Set<String> REFRESH_COOKIE_URL = Set.of("/auth/signout", "/auth/signout");
private final JwtUtils jwtUtils;
public JwtAuthenticationFilter(JwtUtils jwtUtils) {
this.jwtUtils = jwtUtils;
}
/**
* Filters the request and validates the JWT token
*
* @param request HTTP request
* @param response HTTP response
* @param filterChain Filter chain
* @throws ServletException
* @throws IOException
*/
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
try {
/**
* If the request url is whitelisted, then no need to validate the JWT token
*/
if (isWhitelistedOrAuthenticated(request)) {
filterChain.doFilter(request, response);
return;
}
/**
* For /refresh and /logout we expect the refresh token,
* for all other urls we expect the access token.
*/
boolean isRefreshCookieUrl = REFRESH_COOKIE_URL.contains(request.getRequestURI());
TokenType tokenType = isRefreshCookieUrl ? TokenType.REFRESH_TOKEN : TokenType.ACCESS_TOKEN;
String token = getJwtFromRequest(request, isRefreshCookieUrl);
UsernameEmailPasswordAuthenticationToken authentication = jwtUtils.validateToken(token, tokenType);
/**
* Store the authenticated authentication object in the security context
* so userId, authorities can be used by downstream components, e.g. controllers, services, etc.
*/
if (getContext().getAuthentication() == null) {
getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
} catch (AuthenticationException | JwtException failed) {
handleUnsuccessfulAuthentication(request, response, failed);
}
}
/**
* Handles unsuccessful authentication
*
* @param request HTTP request
* @param response HTTP response
* @param failed Authentication exception
* @throws IOException
*/
private void handleUnsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, RuntimeException failed) throws IOException {
ResponseEntity<String> responseEntity = ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Error: " + failed.getMessage());
response.setContentType("application/json");
response.setStatus(responseEntity.getStatusCode().value());
response.getWriter().write(new ObjectMapper().writeValueAsString(responseEntity.getBody()));
}
/**
* Extracts JWT token from the request
*
* @param request HTTP request
* @return JWT token
*/
private String getJwtFromRequest(HttpServletRequest request, boolean isRefreshCookieUrl) {
Cookie[] cookies = request.getCookies();
/**
* If the request URL is a refresh token URL, then extract the refresh-token from the cookie
*/
if (isRefreshCookieUrl) {
if (cookies != null) {
return Arrays.stream(cookies)
.filter(cookie -> REFRESH_COOKIE.equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.orElseThrow(() -> new JwtException("Refresh token is missing"));
}
}
/**
* For all other url extract the access token from the Authorization header or the access-token cookie
*/
String bearerToken = request.getHeader(AUTHORIZATION);
if (bearerToken != null && bearerToken.startsWith(BEARER + " ")) {
return bearerToken.substring(7);
}
if (cookies != null) {
return Arrays.stream(cookies)
.filter(cookie -> ACCESS_COOKIE.equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.orElseThrow(() -> new JwtException("Access token is missing"));
}
throw new JwtException("JWT token is missing");
}
/**
* Checks if the request URL is whitelisted
*
* @param request HTTP request
* @return true if the request URL is whitelisted, false otherwise
*/
private boolean isWhitelistedOrAuthenticated(HttpServletRequest request) {
String requestURI = request.getRequestURI();
Authentication authentication = getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
return true;
}
AntPathMatcher pathMatcher = new AntPathMatcher();
boolean isWhitelistedURL = Stream.of(SecurityConfig.WHITE_LIST_URL)
.anyMatch(pattern -> pathMatcher.match(pattern, requestURI));
return isWhitelistedURL;
}
}
The isWhitelistedOrAuthenticated function tells us that the filter won’t be handling requests that is for a whitelist url or request that is authenticated. All other requests requires a JWT token.
Modify to SecurityConfig.java
public static final String[] WHITE_LIST_URL = {
"/public/**",
"/swagger-ui/**", // Swagger UI
"/v3/api-docs/**", // Swagger API docs
"/auth/signup", // Signup endpoint
};
@Bean
public JwtUtils jwtUtils(@Value("${JWT_ACCESS_SIGNING_KEY}") String accessSigningKey,
@Value("${JWT_REFRESH_SIGNING_KEY}") String refreshSigningKey,
UserService userService) {
return new JwtUtils(accessSigningKey, refreshSigningKey, userService);
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtUtils jwtUtils) {
return new JwtAuthenticationFilter(jwtUtils);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter,
UsernameEmailPasswordAuthenticationFilter usernameEmailPasswordAuthenticationFilter) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Disable CSRF protection
.authorizeHttpRequests(auth -> auth
.requestMatchers(WHITE_LIST_URL).permitAll() // Whitelist signup endpoint
.anyRequest().authenticated() // All other endpoints require authentication
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // Stateless session management
)
.authenticationProvider(authenticationProvider()) // Register custom authentication provider
.addFilterAt(usernameEmailPasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
When we update our session creation strategy to STATELESS , our application won’t be using JSESSIONID to track sessions.
@Value(“${JWT_ACCESS_SIGNING_KEY}”) and @Value(“${JWT_REFRESH_SIGNING_KEY}”) tells our application to fetch these values from the environmental variable JWT_ACCESS_SIGNING_KEY and JWT_REFRESH_SIGNING_KEY .
.addFilterAfter tells application to add the JWTAuthenticationFilter after the UsernamePasswordAuthenticationFilter.
Updating Authentication Endpoints
@Autowired
private JwtUtils jwtUtils;
/**
* When the username/email and password are validated during signin, an access token and refresh token are returned
* to the user. The access token is used to authenticate the user for a short period of time, so it is passed back to
* user in the response body along with the expiry time.
* While the refresh token are longer lived and are stored in an HTTP-only cookie on the user's browser.
*/
@PostMapping("/auth/signin")
public ResponseEntity<?> authenticateUser(@RequestBody SigninRequest signinRequest, HttpServletResponse response) {
/** The reason for storing the authentication object in the SecurityContextHolder because SecurityContextHolder is thread local
* store meaning that our authentication object is global to the thread. You can access the authentication object from
* anywhere in the application without worrying about leaking the authentication object to other threads. Because while
* SecurityContextHolder is global to the thread, it provides isolation because the authentication object isolated to
* the thread. By thread, we mean the request thread that is processing the request.
* We stored the userId, username, email, and authorities in the authentication object, which means we can
* access the user's information from anywhere in the application.
*/
UsernameEmailPasswordAuthenticationToken authentication = (UsernameEmailPasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
String accessToken = jwtUtils.generateToken(authentication, TokenType.ACCESS_TOKEN);
String refreshToken = jwtUtils.generateToken(authentication, TokenType.REFRESH_TOKEN);
userService.updateTokens(authentication.getId(), accessToken, refreshToken);
// Set refresh token in HTTP-only cookie
Cookie refreshTokenCookie = new Cookie("refresh-token", refreshToken);
refreshTokenCookie.setHttpOnly(true); // prevents JavaScript from accessing the cookie
refreshTokenCookie.setSecure(true); // should be set to true in production
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge((int) JwtUtils.REFRESH_TOKEN_EXPIRATION_TIME.toSeconds()); // Evicts the cookie from browser when the token expires
response.addCookie(refreshTokenCookie);
return ResponseEntity.ok(new AccessTokenResponse(accessToken, JwtUtils.ACCESS_TOKEN_EXPIRATION_TIME.toSeconds()));
}
/**
* Uses the refresh token to authenticate the user and generate a new access token. We use the refresh token for authentication
* because the user would call this endpoint to get a new access token when the current access token expires.
* A new access token is generated and returned to the user, and replace the old access token in the database with the new access token.
*/
@PostMapping("/auth/refresh")
public ResponseEntity<?> refreshToken(HttpServletResponse response) {
UsernameEmailPasswordAuthenticationToken authentication = (UsernameEmailPasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
String accessToken = jwtUtils.generateToken(authentication, TokenType.ACCESS_TOKEN);
userService.updateAccessToken(authentication.getId(), accessToken);
return ResponseEntity.ok(new AccessTokenResponse(accessToken, JwtUtils.ACCESS_TOKEN_EXPIRATION_TIME.toSeconds()));
}
/**
* We are using the refresh token to authenticate the user during logout.
* When the user is successfully authenticated we remove the user's access and refresh token from the database.
* We will also remove the refresh token from the user's browser by setting the cookie to expire immediately.
*/
@PostMapping("/auth/signout")
public ResponseEntity<?> logoutUser(HttpServletResponse response) {
// Get the authenticated user's ID from security context
String userId = ((UsernameEmailPasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication()).getId();
userService.deleteTokens(userId);
Cookie refreshTokenCookie = new Cookie("refresh-token", null);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setSecure(true);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(0); // Remove the refresh token from the user's browser by setting the cookie to expire immediately.
response.addCookie(refreshTokenCookie);
return ResponseEntity.ok("Signout successful");
}
The reason we return the refresh token as an HttpOnly, Secure cookie is due to its longer lifespan and higher security requirements. The most secure method for handling refresh tokens is through HttpOnly, Secure cookies, which prevent access to the token via client-side JavaScript, thereby reducing the risk of XSS (Cross-Site Scripting) and MITM (Man-in-the-Middle) attacks.
Meanwhile, the access token, which is shorter-lived, can be returned in the response body.
Setting Up Environment Variables
Generate two secret keys, each at least 64 characters long (as required by the HS512 algorithm). These secret keys will be used separately for the refresh and access tokens. Using two distinct secret keys makes our application more secure.
Then go to https://dashboard.render.com/, click into your Application, then the Environment tab, and add your keys under JWT_ACCESS_SIGNING_KEY and JWT_REFRESH_SIGNING_KEY .