JWT Authentication: Securing the API

SS Saurav Sitaula

From 'anyone can delete anything' to proper security. Learn to implement JWT authentication in Spring Boot with Spring Security, protect your endpoints, handle login/logout, and make your React app work with tokens.

The Security Wake-Up Call

My API was working great. Database persistence with MyBatis. Full CRUD operations. React talking to Spring Boot seamlessly.

Then a colleague pointed something out:

“Can I just… delete any user? Without logging in?”

curl -X DELETE http://localhost:8080/api/users/1

User deleted. No authentication. No questions asked.

Oh no.

The Plan: JWT Authentication

After researching authentication options, I settled on JWT (JSON Web Tokens):

  1. User logs in with email/password
  2. Server validates credentials and returns a JWT
  3. Client stores the JWT (localStorage or httpOnly cookie)
  4. Client sends JWT with every request
  5. Server validates JWT before processing

It’s stateless (no server-side sessions), works great with React SPAs, and is the standard for REST APIs.

Adding Spring Security

First, the dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

The moment I added spring-boot-starter-security, everything broke.

GET /api/users
401 Unauthorized

Every endpoint. Locked down. By default, Spring Security protects everything. Which is… actually good? But I needed to configure it.

The JWT Utility Class

First, a utility to create and validate JWTs:

package com.mycompany.myapi.security;

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

import java.security.Key;
import java.util.Date;

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    private Key getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes());
    }

    public String generateToken(String email) {
        return Jwts.builder()
                .setSubject(email)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public String extractEmail(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(getSigningKey())
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }
}

In application.properties:

jwt.secret=your-256-bit-secret-key-here-make-it-long-and-random
jwt.expiration=86400000

The expiration is in milliseconds. 86400000 = 24 hours.

The User Entity (Updated for Auth)

I added password storage to the user model:

public class User {
    private Long id;
    private String name;
    private String email;
    private String password;  // Hashed, never plain text!
    private String createdAt;

    // ... getters and setters
}

And updated the mapper:

<insert id="insert" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO users (name, email, password)
    VALUES (#{name}, #{email}, #{password})
</insert>

<select id="findByEmail" resultMap="UserResultMap">
    SELECT id, name, email, password, created_at
    FROM users
    WHERE email = #{email}
</select>

The Authentication Controller

package com.mycompany.myapi.controller;

import com.mycompany.myapi.model.User;
import com.mycompany.myapi.security.JwtUtil;
import com.mycompany.myapi.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/api/auth")
@CrossOrigin(origins = "http://localhost:3000")
public class AuthController {

    private final UserService userService;
    private final JwtUtil jwtUtil;
    private final PasswordEncoder passwordEncoder;

    public AuthController(UserService userService, JwtUtil jwtUtil, 
                          PasswordEncoder passwordEncoder) {
        this.userService = userService;
        this.jwtUtil = jwtUtil;
        this.passwordEncoder = passwordEncoder;
    }

    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
        // Check if email exists
        if (userService.findByEmail(request.getEmail()).isPresent()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "Email already registered"));
        }

        // Create user with hashed password
        User user = new User();
        user.setName(request.getName());
        user.setEmail(request.getEmail());
        user.setPassword(passwordEncoder.encode(request.getPassword()));

        User created = userService.create(user);

        // Generate token
        String token = jwtUtil.generateToken(created.getEmail());

        Map<String, Object> response = new HashMap<>();
        response.put("token", token);
        response.put("user", sanitizeUser(created));

        return ResponseEntity.ok(response);
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        // Find user
        User user = userService.findByEmail(request.getEmail())
                .orElse(null);

        if (user == null) {
            return ResponseEntity.status(401)
                    .body(Map.of("error", "Invalid credentials"));
        }

        // Check password
        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
            return ResponseEntity.status(401)
                    .body(Map.of("error", "Invalid credentials"));
        }

        // Generate token
        String token = jwtUtil.generateToken(user.getEmail());

        Map<String, Object> response = new HashMap<>();
        response.put("token", token);
        response.put("user", sanitizeUser(user));

        return ResponseEntity.ok(response);
    }

    private Map<String, Object> sanitizeUser(User user) {
        Map<String, Object> safe = new HashMap<>();
        safe.put("id", user.getId());
        safe.put("name", user.getName());
        safe.put("email", user.getEmail());
        return safe;  // No password in response!
    }
}

The request DTOs:

public class RegisterRequest {
    private String name;
    private String email;
    private String password;
    // getters and setters
}

public class LoginRequest {
    private String email;
    private String password;
    // getters and setters
}

The JWT Filter

This filter runs on every request, checking for a valid JWT:

package com.mycompany.myapi.security;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

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

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

        String authHeader = request.getHeader("Authorization");

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);

            if (jwtUtil.validateToken(token)) {
                String email = jwtUtil.extractEmail(token);

                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                email, null, new ArrayList<>()
                        );

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

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

        filterChain.doFilter(request, response);
    }
}

Security Configuration

The big one. Configuring what’s protected and what’s not:

package com.mycompany.myapi.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
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
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
        this.jwtFilter = jwtFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors().and()
            .csrf().disable()  // Disable CSRF for stateless API
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
                // Public endpoints
                .antMatchers("/api/auth/**").permitAll()
                .antMatchers("/api/public/**").permitAll()
                // Everything else requires authentication
                .anyRequest().authenticated()
            .and()
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

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

Key points:

  • CSRF disabled (standard for stateless APIs)
  • Session creation disabled (JWTs are stateless)
  • /api/auth/** is public (login/register)
  • Everything else requires authentication
  • JWT filter runs before Spring’s default authentication

Connecting from React

Now React needs to handle tokens:

// Login
const login = async (email, password) => {
  const response = await fetch('http://localhost:8080/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  if (!response.ok) {
    throw new Error('Invalid credentials');
  }

  const data = await response.json();
  localStorage.setItem('token', data.token);
  setUser(data.user);
};

// Authenticated request
const fetchUsers = async () => {
  const token = localStorage.getItem('token');

  const response = await fetch('http://localhost:8080/api/users', {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  });

  if (response.status === 401) {
    // Token expired or invalid
    localStorage.removeItem('token');
    setUser(null);
    return;
  }

  const data = await response.json();
  setUsers(data);
};

// Logout
const logout = () => {
  localStorage.removeItem('token');
  setUser(null);
};

A better approach is using an Axios interceptor:

import axios from 'axios';

const api = axios.create({
  baseURL: 'http://localhost:8080/api',
});

// Add token to every request
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Handle 401 responses
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default api;

Getting the Current User

In protected endpoints, you often need to know WHO is making the request:

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/me")
    public ResponseEntity<User> getCurrentUser() {
        // Get email from SecurityContext (set by JWT filter)
        String email = SecurityContextHolder.getContext()
                .getAuthentication()
                .getName();

        return userService.findByEmail(email)
                .map(this::sanitizeUser)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

Or inject it directly:

@GetMapping("/me")
public ResponseEntity<User> getCurrentUser(
        @AuthenticationPrincipal String email) {
    // email is injected directly
    return userService.findByEmail(email)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

Role-Based Access Control

Want some endpoints only for admins?

Update the User model:

public class User {
    // ... existing fields
    private String role;  // "USER" or "ADMIN"
}

Update the JWT filter to include roles:

// In JwtUtil - include role in token
public String generateToken(String email, String role) {
    return Jwts.builder()
            .setSubject(email)
            .claim("role", role)
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(getSigningKey(), SignatureAlgorithm.HS256)
            .compact();
}

// In JwtAuthenticationFilter
String role = jwtUtil.extractRole(token);
List<GrantedAuthority> authorities = List.of(
    new SimpleGrantedAuthority("ROLE_" + role)
);

UsernamePasswordAuthenticationToken authentication =
    new UsernamePasswordAuthenticationToken(email, null, authorities);

Then protect endpoints:

@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
    // Only admins can delete
}

Or in SecurityConfig:

.antMatchers(HttpMethod.DELETE, "/api/users/**").hasRole("ADMIN")

Common Gotchas

1. CORS + Credentials

If you’re using cookies or Authorization headers:

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:3000")
                .allowedMethods("*")
                .allowedHeaders("*")
                .allowCredentials(true);  // Important!
    }
}

2. Token in Response Headers

Some prefer returning the token in a header:

return ResponseEntity.ok()
    .header("Authorization", "Bearer " + token)
    .body(sanitizeUser(user));

3. Refresh Tokens

JWTs expire. For better UX, implement refresh tokens—a separate long-lived token that can get new access tokens without re-login.

What I Wish I’d Known Earlier

  1. Never store plain text passwords. Always use BCryptPasswordEncoder or similar.

  2. Keep the JWT secret actually secret. Use environment variables, not hardcoded strings.

  3. Set reasonable expiration times. Too short = annoying. Too long = security risk.

  4. Handle token expiration in React. Don’t just show “unauthorized”—redirect to login.

  5. Consider httpOnly cookies. localStorage is vulnerable to XSS. Cookies with httpOnly flag are more secure.

The Journey Continues

I had authentication! Only logged-in users could access the API. Roles controlled who could do what. React stored and sent tokens automatically.

This was the final piece of my backend puzzle. I could now build real, production-ready APIs.


P.S. — If Spring Security feels overwhelming, you’re not alone. The documentation is dense, and there are multiple ways to configure everything. Start simple, get it working, then add complexity. Don’t try to implement OAuth2, refresh tokens, and social login all at once.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism