Understanding JWT in Spring Boot

박진석·2025년 2월 6일
post-thumbnail

Understanding JWT in Spring Boot

The Conference Badge Analogy

Think of JWT like attending an exclusive multi-day conference. When we first register, we receive 2 important items:

  • A conference badge (access token) that expires daily for security
  • A special pass (refresh token) that lets you get new badges without re-registering

Background

Before JWT, web applications relied on session-based authentication. This approach stored session information on servers, creating scalability challenges as applications grew. JWT emerged as a solution by introducing stateless authentication – instead of maintaining server-side sessions, the server generates a signed token containing user information that clients store and include with each request.

JWT Structure

A JWT consists of three distinct sections, separated by periods:

aaaa.bbbb.cccc
  1. Header: Contains token metadata
  2. Payload: Stores the actual data (claims)
  3. Signature: Ensures token integrity

Understanding JWT Authentication Headers in Spring Boot

Think of the authentication header like a VIP pass that we show at each checkpoint - it consists of two parts: the type of pass ("Bearer") and our unique identifier (the JWT token).

The Authentication Header Structure

The HTTP header for JWT authentication follows this format:

Authorization: Bearer aaaa.bbbb.cccc

The word "Bearer" indicates the authentication scheme, followed by a space and then the JWT token. This scheme tells the server "the bearer of this token should be granted access to the requested resources."

Implementation in Spring Boot

Backend Structure

On the server side, we need to extract and validate the token from the Authorization header:

Copy// User entity
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(unique = true)
    private String username;
    
    private String password;
    
    // Getters, setters, and constructors
}

// DTO for login requests
public class LoginRequest {
    private String username;
    private String password;
    // Getters, setters
}

// DTO for authentication response
public class AuthResponse {
    private String accessToken;
    private String refreshToken;
    // Getters, setters
}

// JWT utility class
@Component
public class JwtUtil {
    @Value("${jwt.secret}")
    private String secret;
    
    @Value("${jwt.expiration}")
    private Long expiration;
    
    public String generateToken(String username) {
        return Jwts.builder()
            .setSubject(username)
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();
    }
    
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }
}

// Authentication controller
@RestController
@RequestMapping("/api/auth")
public class AuthController {
    @Autowired
    private AuthenticationManager authenticationManager;
    
    @Autowired
    private JwtUtil jwtUtil;
    
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
        try {
            Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                    loginRequest.getUsername(),
                    loginRequest.getPassword()
                )
            );
            
            String accessToken = jwtUtil.generateToken(loginRequest.getUsername());
            String refreshToken = jwtUtil.generateRefreshToken(loginRequest.getUsername());
            
            return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken));
        } catch (AuthenticationException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
    }
}

// Protected resource controller
@RestController
@RequestMapping("/api/protected")
public class ProtectedController {
    @GetMapping("/data")
    public ResponseEntity<?> getProtectedData() {
        return ResponseEntity.ok("This is protected data");
    }
}

Frontend Structure

// src/services/authService.js
import axios from 'axios';

const API_URL = 'http://localhost:8080/api';

const axiosInstance = axios.create({
    baseURL: API_URL
});

// Add token to requests
axiosInstance.interceptors.request.use(
    (config) => {
        const token = localStorage.getItem('accessToken');
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
    },
    (error) => {
        return Promise.reject(error);
    }
);

// Handle token refresh
axiosInstance.interceptors.response.use(
    (response) => response,
    async (error) => {
        const originalRequest = error.config;
        
        if (error.response.status === 401 && !originalRequest._retry) {
            originalRequest._retry = true;
            
            try {
                const refreshToken = localStorage.getItem('refreshToken');
                const response = await axios.post(`${API_URL}/auth/refresh`, { refreshToken });
                
                localStorage.setItem('accessToken', response.data.accessToken);
                
                return axiosInstance(originalRequest);
            } catch (refreshError) {
                // Redirect to login if refresh fails
                window.location.href = '/login';
                return Promise.reject(refreshError);
            }
        }
        
        return Promise.reject(error);
    }
);

// src/components/Login.jsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';

const Login = () => {
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');
    const [error, setError] = useState('');
    const navigate = useNavigate();
    
    const handleLogin = async (e) => {
        e.preventDefault();
        try {
            const response = await axiosInstance.post('/auth/login', {
                username,
                password
            });
            
            localStorage.setItem('accessToken', response.data.accessToken);
            localStorage.setItem('refreshToken', response.data.refreshToken);
            
            navigate('/dashboard');
        } catch (error) {
            setError('Invalid credentials');
        }
    };
    
    return (
        <div className="min-h-screen flex items-center justify-center bg-gray-50">
            <div className="max-w-md w-full space-y-8">
                <form onSubmit={handleLogin} className="mt-8 space-y-6">
                    <div>
                        <input
                            type="text"
                            value={username}
                            onChange={(e) => setUsername(e.target.value)}
                            placeholder="Username"
                            className="appearance-none rounded-md relative block w-full px-3 py-2 border"
                        />
                    </div>
                    <div>
                        <input
                            type="password"
                            value={password}
                            onChange={(e) => setPassword(e.target.value)}
                            placeholder="Password"
                            className="appearance-none rounded-md relative block w-full px-3 py-2 border"
                        />
                    </div>
                    {error && <div className="text-red-500">{error}</div>}
                    <button
                        type="submit"
                        className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
                    >
                        Sign in
                    </button>
                </form>
            </div>
        </div>
    );
};

// src/components/ProtectedRoute.jsx
import { Navigate } from 'react-router-dom';

const ProtectedRoute = ({ children }) => {
    const isAuthenticated = !!localStorage.getItem('accessToken');
    
    return isAuthenticated ? children : <Navigate to="/login" />;
};

// src/App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const App = () => {
    return (
        <BrowserRouter>
            <Routes>
                <Route path="/login" element={<Login />} />
                <Route
                    path="/dashboard"
                    element={
                        <ProtectedRoute>
                            <Dashboard />
                        </ProtectedRoute>
                    }
                />
            </Routes>
        </BrowserRouter>
    );
};

Key Features Implemented

  1. Token Management: The frontend stores both access and refresh tokens in localStorage
  2. Automatic Token Refresh: Uses axios interceptors to handle 401 errors and refresh tokens
  3. Protected Routes: Implements route protection using React Router
  4. Error Handling: Proper error handling for authentication failures
  5. Secure Headers: Automatically adds Bearer token to authenticated requests

Configuration in Spring Security

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private JwtAuthenticationFilter jwtAuthFilter;
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .authorizeRequests()
            .antMatchers("/auth/**").permitAll()
            .anyRequest().authenticated();
    }
}

Security Considerations

When working with Bearer tokens, keep these in mind:

  1. Always use HTTPS to prevent token interception through man-in-the-middle attacks.
  2. Implement token expiration and refresh mechanisms to limit the impact of stolen tokens.
  3. Consider implementing token blacklisting for logged-out users.
  4. Add rate limiting to prevent brute force attacks.
  5. Implement proper error handling for malformed or missing Authorization headers.

The combination of proper header formatting and robust server-side validation creates a secure authentication system. When a client includes the Bearer token in their requests, it's like presenting a digital ID card that proves their identity and permissions for each operation they want to perform.

Token Lifecycle Management

Understanding Token Expiration

Access tokens intentionally have short lifespans (15-60 minutes) as a security measure. This brief duration limits potential damage if a token is compromised. However, relying solely on access tokens would force users to frequently log in again – creating a poor user experience.

The Role of Refresh Tokens

Refresh tokens solve this usability challenge by:

  • Maintaining longer validity periods (days or weeks)
  • Being stored in secure locations (typically HTTP-only cookies)
  • Having limited functionality (only used for access token renewal)
  • Supporting revocation when needed

Implementing Token Refresh

@Service
public class TokenService {
    @Autowired
    private JwtConfig jwtConfig;
    
    public TokenResponse refreshAccessToken(String refreshToken) {
        // First, ensure the refresh token is valid
        if (!isValidRefreshToken(refreshToken)) {
            throw new InvalidTokenException("Refresh token is invalid or expired");
        }
        
        // Extract user information from the refresh token
        String username = extractUsername(refreshToken);
        UserDetails userDetails = userService.loadUserByUsername(username);
        
        // Generate a fresh access token
        String newAccessToken = jwtConfig.generateToken(userDetails);
        
        return new TokenResponse(newAccessToken, refreshToken);
    }
}

Security Best Practices

  1. Database Storage: Maintain a record of refresh tokens to enable revocation
  2. Token Rotation: Generate new refresh tokens during each refresh operation
  3. Secure Storage: Use HTTP-only cookies for refresh tokens
  4. Error Management: Implement comprehensive error handling for token expiration
  5. Token Validation: Always verify token signatures and expiration times
  6. Security Headers: Implement appropriate security headers (CORS, CSP, etc.)

Practical Considerations

When implementing JWT authentication in Spring Boot, consider these aspects:

  1. Token Size: Keep tokens compact by including only essential information
  2. Rate Limiting: Implement rate limiting for token refresh endpoints
  3. Logging: Maintain audit logs for token generation and refresh operations
  4. Monitoring: Set up alerts for unusual token usage patterns

0개의 댓글