Authentication is the backbone of any secure application. In this comprehensive guide, I'll walk you through implementing JWT (JSON Web Token) authentication in a Spring Boot backend with a React frontend—the exact pattern I've used at Amazon, Agoda, and multiple startups.
Why JWT for Modern Web Applications?
After building authentication systems for applications handling millions of users, I've learned that JWT strikes the perfect balance between security, scalability, and simplicity. Here's why:
- Stateless: No server-side session storage needed, perfect for distributed systems
- Scalable: Tokens contain all necessary user information, enabling horizontal scaling
- Cross-domain: Works seamlessly across different domains and microservices
- Mobile-friendly: Same token mechanism works for web and mobile apps
Architecture Overview
Our authentication flow follows industry best practices with two types of tokens:
- Access Token: Short-lived (15 minutes), used for API requests
- Refresh Token: Long-lived (7 days), used to obtain new access tokens
User Login Flow:
1. User submits credentials (email + password)
2. Server validates credentials
3. Server generates access token (15 min) + refresh token (7 days)
4. Tokens sent to client
5. Client stores access token in memory, refresh token in httpOnly cookie
6. Client includes access token in Authorization header for API calls
7. When access token expires, client uses refresh token to get new one
Backend Implementation: Spring Boot
1. Dependencies (pom.xml)
<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>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
2. JWT Utility Class
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-expiration}")
private Long accessTokenExpiration;
public String generateAccessToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isTokenValid(String token, String username) {
return username.equals(extractUsername(token)) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private Key getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
return Keys.hmacShaKeyFor(keyBytes);
}
}
Frontend Implementation: React
API Service with Axios
import axios from 'axios';
const API_BASE_URL = 'http://localhost:8080/api';
let accessToken = null;
const api = axios.create({
baseURL: API_BASE_URL,
withCredentials: true,
});
api.interceptors.request.use(
(config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
}
);
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
const { data } = await axios.post(
`${API_BASE_URL}/auth/refresh`,
{},
{ withCredentials: true }
);
accessToken = data.accessToken;
return api(error.config);
}
return Promise.reject(error);
}
);
export default api;
Security Best Practices
1. Token Storage
- Access Token: Store in memory (JavaScript variable), never localStorage
- Refresh Token: Store in httpOnly cookie with Secure and SameSite flags
- Why? Prevents XSS attacks from stealing tokens
2. Token Expiration
- Short-lived access tokens (15 minutes) limit damage if compromised
- Longer refresh tokens (7 days) keep users signed in without re-entering credentials
- Rotate refresh tokens on every use so a stolen token is invalidated the moment the real user refreshes
3. Transport & Storage Hardening
- Always use HTTPS in production so tokens are never sent in clear text
- Set Secure, HttpOnly, and SameSite=Strict on the refresh-token cookie
- Keep the signing secret out of source control — load it from environment variables or a secrets manager
- Maintain a token blacklist (or short expiry + rotation) so logout and compromise can revoke access
Wrapping Up
That's the full pattern: short-lived access tokens for API calls, rotating refresh tokens in an HttpOnly cookie, and a stateless backend that scales horizontally. Get the storage and expiration right, keep the secret out of your repo, and you have an authentication layer that holds up in production — the same one I've shipped for apps serving millions of users.
Questions or a pattern you'd do differently? I'd like to hear it — reach out via the contact page.