Java Spring Boot Backend

Spring Boot JWT Authentication — Complete Guide

Spring boot jwt authentication is one of the most common security patterns in modern Java backend development. If you are building REST APIs for a web app, mobile app, SaaS dashboard, or microservice, you need a way to authenticate users without storing server-side sessions for every request. JSON Web Tokens, usually called JWTs, solve that problem by letting the server issue a signed token after login and verify that token on future API calls.

In this complete guide, you will build JWT authentication with Java 21, Spring Boot 3.x, Spring Security 6, and Gradle. We will create a user entity, repository layer, request and response DTOs, JWT utility class, security configuration, authentication filter, service layer, controller, Postman tests, curl commands, common errors, best practices, production notes, interview questions, and an FAQ section.

Goal: by the end, you will understand how JWT login works, how Spring Security validates a bearer token, and how to protect REST endpoints in a real backend.

What is JWT?

JWT stands for JSON Web Token. It is a compact token format used to safely send claims between systems. A claim is simply a piece of information, such as a username, user ID, role, issue time, or expiration time. A JWT has three parts:

  • Header - describes the token type and signing algorithm.
  • Payload - stores claims such as subject, roles, and expiration.
  • Signature - proves the token was created by a trusted server.

A JWT looks like a long string with three dot-separated sections. The payload is encoded, not encrypted, so never put passwords, secrets, credit card data, or private user data inside a JWT. The signature prevents tampering, but it does not hide the payload.

Why JWT is Used in Modern Applications

JWT is popular because it fits REST APIs well. Traditional session authentication stores session data on the server. That works beautifully for many server-rendered apps, but API platforms often need to serve browsers, mobile apps, third-party clients, and multiple backend services. JWT makes authentication stateless: the client sends the token with each request, and the server verifies it without reading a session table.

This makes JWT useful for horizontal scaling, API gateways, mobile clients, and systems where multiple services need to trust the same login result. JWT is not magic security, though. You still need HTTPS, strong signing keys, short expiration times, password hashing, careful refresh-token design, and proper authorization checks.

How JWT Authentication Works

The login flow is simple. A user sends credentials to an authentication endpoint. The server verifies the email and password. If the credentials are valid, the server generates an access token and returns it to the client. The client stores that token and sends it in the Authorization header on future requests.

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

The access token is usually short-lived. A common expiration is 15 minutes to 1 hour, depending on the risk level of your application. For this beginner-friendly tutorial, we will create an access token only. In production, you usually add refresh tokens so users can stay logged in without issuing long-lived access tokens.

Stateless authentication means the API does not need to remember the user between requests. Every request carries the token. The JWT filter reads the token, validates the signature and expiration, extracts the username, loads the user, and places an authentication object into Spring Security's context.

Project Setup

Create a new Spring Boot project from Spring Initializr or your IDE. Use these choices:

  • Language: Java
  • Java version: 21
  • Build tool: Gradle
  • Spring Boot: 3.x
  • Dependencies: Spring Web, Spring Security, Spring Data JPA, Validation, H2 Database

H2 keeps the tutorial easy to run. Later, you can replace it with PostgreSQL. For a broader API foundation, read the Spring Boot REST API Tutorial. For database-focused work, add a Spring Boot PostgreSQL CRUD Guide to your next reading list.

Gradle Configuration

Here is a complete build.gradle for Spring Boot 3.x, Java 21, Spring Security, JPA, validation, H2, and the JJWT library.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.0'
    id 'io.spring.dependency-management' version '1.1.5'
}

group = 'com.digitaldrift'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

    runtimeOnly 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

Application Configuration

Add these values to src/main/resources/application.properties. The JWT secret below is only for local development. Use an environment variable or secret manager in production.

spring.application.name=spring-boot-jwt-auth

spring.datasource.url=jdbc:h2:mem:authdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

jwt.secret=change-this-local-secret-key-change-this-local-secret-key
jwt.expiration-ms=3600000

Project Structure

Keep the security code separated by responsibility. A clear package structure helps beginners understand where each piece belongs.

src/main/java/com/digitaldrift/auth/
├── SpringBootJwtAuthApplication.java
├── config/
│   └── SecurityConfig.java
├── controller/
│   └── AuthController.java
├── dto/
│   ├── LoginRequest.java
│   ├── LoginResponse.java
│   └── RegisterRequest.java
├── entity/
│   └── AppUser.java
├── repository/
│   └── UserRepository.java
├── security/
│   ├── JwtAuthenticationFilter.java
│   └── JwtUtil.java
└── service/
    └── AuthService.java

Create User Entity

The user entity stores login identity, hashed password, and role. Avoid naming the table user because some databases reserve that word.

package com.digitaldrift.auth.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "app_users")
public class AppUser {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String role = "ROLE_USER";

    protected AppUser() {
    }

    public AppUser(String email, String password, String role) {
        this.email = email;
        this.password = password;
        this.role = role;
    }

    public Long getId() {
        return id;
    }

    public String getEmail() {
        return email;
    }

    public String getPassword() {
        return password;
    }

    public String getRole() {
        return role;
    }
}

Create Repository Layer

Spring Data JPA creates the query implementation at runtime. We need one method for finding users by email and one method for checking duplicates during registration.

package com.digitaldrift.auth.repository;

import com.digitaldrift.auth.entity.AppUser;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<AppUser, Long> {
    Optional<AppUser> findByEmail(String email);
    boolean existsByEmail(String email);
}

Create DTOs

DTOs keep external API payloads separate from database entities. This is especially important in authentication because you never want to accidentally return a password hash.

package com.digitaldrift.auth.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record RegisterRequest(
    @Email(message = "Email must be valid")
    @NotBlank(message = "Email is required")
    String email,

    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    String password
) {}
package com.digitaldrift.auth.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record LoginRequest(
    @Email(message = "Email must be valid")
    @NotBlank(message = "Email is required")
    String email,

    @NotBlank(message = "Password is required")
    String password
) {}
package com.digitaldrift.auth.dto;

public record LoginResponse(
    String accessToken,
    String tokenType,
    long expiresIn
) {}

JWT Utility Class

The JWT utility class is responsible for generating tokens, validating tokens, and extracting the username. In this guide, the username is the user's email address. The signing key is created from the configured secret.

package com.digitaldrift.auth.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;

@Component
public class JwtUtil {

    private final SecretKey signingKey;
    private final long expirationMs;

    public JwtUtil(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.expiration-ms}") long expirationMs
    ) {
        this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expirationMs = expirationMs;
    }

    public String generateToken(String username) {
        Instant now = Instant.now();

        return Jwts.builder()
                .subject(username)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusMillis(expirationMs)))
                .signWith(signingKey)
                .compact();
    }

    public boolean validateToken(String token) {
        try {
            getClaims(token);
            return true;
        } catch (Exception ex) {
            return false;
        }
    }

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

    public long getExpirationSeconds() {
        return expirationMs / 1000;
    }

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

Spring Security Configuration

Spring Security 6 uses bean-based configuration. We disable CSRF for stateless APIs, allow registration and login, protect everything else, configure stateless session management, expose an authentication manager, and add our JWT filter before the standard username-password filter.

package com.digitaldrift.auth.config;

import com.digitaldrift.auth.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            JwtAuthenticationFilter jwtAuthenticationFilter,
            AuthenticationProvider authenticationProvider
    ) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
                        .anyRequest().authenticated()
                )
                .authenticationProvider(authenticationProvider)
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider(
            UserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder
    ) {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder);
        return provider;
    }

    @Bean
    public UserDetailsService userDetailsService(
            com.digitaldrift.auth.repository.UserRepository userRepository
    ) {
        return username -> userRepository.findByEmail(username)
                .map(user -> org.springframework.security.core.userdetails.User
                        .withUsername(user.getEmail())
                        .password(user.getPassword())
                        .roles(user.getRole().replace("ROLE_", ""))
                        .build()
                )
                .orElseThrow(() ->
                        new org.springframework.security.core.userdetails.UsernameNotFoundException(
                                "User not found"
                        )
                );
    }

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

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration configuration
    ) throws Exception {
        return configuration.getAuthenticationManager();
    }
}

JWT Authentication Filter

The filter runs once per request. It checks whether the request has a bearer token. If the token is present and valid, it loads the user and sets authentication in the security context. After that, protected controllers can execute normally.

package com.digitaldrift.auth.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtUtil jwtUtil, UserDetailsService userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);

        if (!jwtUtil.validateToken(token)) {
            filterChain.doFilter(request, response);
            return;
        }

        String username = jwtUtil.extractUsername(token);

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                    );

            authentication.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request)
            );

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

Authentication Service

The service owns registration and login logic. Registration hashes the password before saving. Login delegates credential verification to Spring Security's authentication manager. If authentication succeeds, the service generates a JWT access token.

package com.digitaldrift.auth.service;

import com.digitaldrift.auth.dto.LoginRequest;
import com.digitaldrift.auth.dto.LoginResponse;
import com.digitaldrift.auth.dto.RegisterRequest;
import com.digitaldrift.auth.entity.AppUser;
import com.digitaldrift.auth.repository.UserRepository;
import com.digitaldrift.auth.security.JwtUtil;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import static org.springframework.http.HttpStatus.CONFLICT;

@Service
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;

    public AuthService(
            UserRepository userRepository,
            PasswordEncoder passwordEncoder,
            AuthenticationManager authenticationManager,
            JwtUtil jwtUtil
    ) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
    }

    public void register(RegisterRequest request) {
        if (userRepository.existsByEmail(request.email())) {
            throw new ResponseStatusException(CONFLICT, "Email is already registered");
        }

        AppUser user = new AppUser(
                request.email(),
                passwordEncoder.encode(request.password()),
                "ROLE_USER"
        );

        userRepository.save(user);
    }

    public LoginResponse login(LoginRequest request) {
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.email(),
                        request.password()
                )
        );

        String token = jwtUtil.generateToken(request.email());
        return new LoginResponse(token, "Bearer", jwtUtil.getExpirationSeconds());
    }
}

Authentication Controller

The controller exposes two public endpoints: register and login. Notice that validation runs before the service is called because the request body uses @Valid.

package com.digitaldrift.auth.controller;

import com.digitaldrift.auth.dto.LoginRequest;
import com.digitaldrift.auth.dto.LoginResponse;
import com.digitaldrift.auth.dto.RegisterRequest;
import com.digitaldrift.auth.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;

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

    @PostMapping("/register")
    @ResponseStatus(HttpStatus.CREATED)
    public Map<String, String> register(@Valid @RequestBody RegisterRequest request) {
        authService.register(request);
        return Map.of("message", "User registered successfully");
    }

    @PostMapping("/login")
    public LoginResponse login(@Valid @RequestBody LoginRequest request) {
        return authService.login(request);
    }
}

Add a Protected Test Endpoint

To confirm authentication works, create a small controller that requires a valid JWT.

package com.digitaldrift.auth.controller;

import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api/profile")
public class ProfileController {

    @GetMapping
    public Map<String, String> profile(Authentication authentication) {
        return Map.of(
                "email", authentication.getName(),
                "message", "You accessed a protected endpoint"
        );
    }
}

Testing with Postman

Start the application with Gradle:

./gradlew bootRun

In Postman, create three requests. First, register a user with POST http://localhost:8080/api/auth/register. Set the body type to JSON and send an email and password. Second, call the login endpoint and copy the returned access token. Third, call GET /api/profile and add the token under Authorization as a Bearer Token.

Example Requests and Responses

Register a user:

curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"dhiraj@example.com","password":"StrongPass123"}'
{
  "message": "User registered successfully"
}

Login and receive a token:

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"dhiraj@example.com","password":"StrongPass123"}'
{
  "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
  "tokenType": "Bearer",
  "expiresIn": 3600
}

Call a protected endpoint:

curl http://localhost:8080/api/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
{
  "email": "dhiraj@example.com",
  "message": "You accessed a protected endpoint"
}

Common Errors and Fixes

  • 403 Forbidden on login: make sure /api/auth/login is listed in permitAll().
  • WeakKeyException: your JWT secret is too short. Use a longer secret for HS256.
  • 401 Unauthorized on protected endpoint: check the Authorization: Bearer header format.
  • Password never matches: store encoded passwords with BCryptPasswordEncoder, not plain text.
  • User not found: confirm your login email matches the email stored during registration.

Common JWT Mistakes

The most common mistake is treating JWT as encrypted storage. Anyone with the token can decode the payload, so keep it minimal. Another mistake is using a long-lived access token. If a token leaks, the attacker can use it until it expires. Keep access tokens short-lived and use refresh tokens carefully.

Developers also forget authorization. Authentication proves who the user is; authorization decides what the user can do. A valid JWT should not automatically grant access to every resource. Always check ownership, roles, and permissions at the service or method level.

Security Best Practices

  • Use HTTPS everywhere so tokens are not exposed in transit.
  • Use strong secrets or asymmetric keys managed outside the codebase.
  • Keep access token expiration short.
  • Hash passwords with BCrypt, Argon2, or another proven password hashing algorithm.
  • Do not store JWTs in localStorage for high-risk browser apps; consider secure HttpOnly cookies.
  • Validate issuer, audience, expiration, and signature in larger systems.
  • Log authentication failures, but never log raw tokens or passwords.

Production Considerations

A production-grade Spring Boot JWT authentication system usually needs refresh tokens, token rotation, logout behavior, device/session tracking, account lockout, rate limiting, audit logs, and centralized secret management. If you use microservices, consider whether every service should verify JWTs directly or whether an API gateway should handle authentication first.

Move secrets out of application.properties. Use environment variables, Kubernetes secrets, AWS Secrets Manager, HashiCorp Vault, or your platform's secret store. For teams, asymmetric signing with public/private keys can be easier to distribute safely than one shared HMAC secret.

Interview Questions

  • What is JWT? A signed token format used to carry claims between systems.
  • Is JWT encrypted? Not by default. It is encoded and signed.
  • Where should the token be sent? Usually in the Authorization: Bearer header.
  • What is stateless authentication? The server verifies each request from the token instead of reading a session.
  • What is the difference between authentication and authorization? Authentication identifies the user; authorization checks permissions.
  • Why use BCrypt? It is designed for password hashing and includes salting and configurable work factor.
  • Why should access tokens expire? Expiration limits damage if a token is stolen.

FAQ

Is spring boot jwt authentication good for beginners?

Yes, if you already understand basic REST controllers. Start with a simple login flow, then add refresh tokens, roles, and database-backed permissions later.

Should I store JWT in localStorage?

It is common in simple demos, but it increases risk if your app has an XSS bug. For sensitive browser apps, secure HttpOnly cookies are often safer.

Do I need a database for JWT authentication?

JWT validation itself does not require a database, but login usually does because you must verify the user's password and account status.

Can I use PostgreSQL instead of H2?

Yes. Replace the H2 dependency and datasource settings with PostgreSQL configuration. The entity, repository, service, and security code can stay mostly the same.

Conclusion

Spring Boot JWT authentication is a practical skill for real-world Java backend development. In this guide, you built a complete login flow with Java 21, Spring Boot 3.x, Gradle, Spring Security, JPA, validation, and JWT. You created a user entity, repository, DTOs, token utility, security configuration, authentication filter, service, controller, protected endpoint, and test requests.

The next step is to make this production-ready: add refresh tokens, connect PostgreSQL, add role-based authorization, rate-limit login attempts, and move secrets out of your local configuration. Once those pieces are in place, you will have a strong foundation for secure backend APIs.

Dhiraj Roy
Dhiraj Roy

Backend developer & tech writer. Writing about Java, Spring Boot, Python, and AI at Digital Drift.