Part 10 of 10 — the finale of Building Instagram's Authentication Backend. If you're just joining, the repo is here and the whole series is on the YouTube playlist.
Booting isn't the same as working
Last part, the app booted. Four containers, all healthy, Flyway running our migration against a real database for the first time. It felt like a finish line.
It wasn't. A server that starts has only proved the wiring is intact. It hasn't proved that a user can register, that the email arrives, that a locked account stays locked, or that a stolen token gets caught. So for this last part I wrote no new code. I used the thing the way a real client would, then tried to break it on purpose.
This post is the written version of that video, with every piece of code the demo touches next to the request that triggers it. You can follow along with nothing but curl, jq and the Docker Compose stack from part 9.
In this post:
- Register, read the OTP in MailHog, verify, log in, and call a protected route
- The account lockout: five wrong passwords, then
423 Locked - The rate limiter: the eleventh request in a minute gets
429 - Refresh token rotation and what happens when a rotated token is replayed
- Password reset, logout, and the Redis blacklist
- The things I found re-reading the code that are still on my fix list
Setup: a few shell variables
Bring the stack up with docker compose up --build. Swagger UI is at localhost:8080/swagger-ui.html and MailHog is at localhost:8025. I'll use curl below so every step is copy-pasteable, but each request works the same way in Swagger.
BASE=http://localhost:8080/api/auth
EMAIL=you@example.com
PASS='SecurePass1!'
Every response, success or error, comes back in the same ApiResponse envelope we built in part 3. Null fields are dropped, so a successful call has no errorCode:
{
"success": true,
"data": { ... },
"timestamp": "2026-10-12T12:30:00.123"
}
Register, read the email, verify
The request body has to survive the validation rules from part 4. The password rule is the strict one: at least eight characters with a lowercase letter, an uppercase letter, a digit and one of @$!%*?&.
AuthDtos.RegisterRequest
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$",
message = "Password must contain uppercase, lowercase, digit, and special character")
private String password;
curl -s -X POST $BASE/register \
-H 'Content-Type: application/json' \
-d "{\"username\":\"prashant\",\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"fullName\":\"Prashant Sharma\"}"
That returns 201. Behind it, AuthService.register from part 6b checks for a duplicate email and username, hashes the password with BCrypt, saves the user as unverified, then generates an OTP and sends the email:
AuthService.register (abridged)
@Transactional
public MessageResponse register(RegisterRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new ConflictException("Email is already registered");
}
if (userRepository.existsByUsername(request.getUsername())) {
throw new ConflictException("Username is already taken");
}
User user = User.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.fullName(request.getFullName())
.authProvider(User.AuthProvider.LOCAL)
.emailVerified(false)
.build();
userRepository.save(user);
// ... generate the OTP, store it in Redis, send the email ...
}
Now the proof. Open MailHog at localhost:8025 and the email is sitting there with a six-digit code. If you'd rather stay in the terminal, MailHog has an API, and our subject line happens to carry the code:
curl -s localhost:8025/api/v2/messages | jq -r '.items[0].Content.Headers.Subject[0]'
# Instagram - Your verification code: 482913
(Putting the OTP in the subject is convenient for a demo and a bad idea for production. More on that at the end.) Send the code to verify-email:
curl -s -X POST $BASE/verify-email \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"otp\":\"482913\"}"
This flips emailVerified to true. That matters in the next step, because User.isEnabled() simply returns that flag, and login refuses to go any further without it.
Login, and the order of the checks
Here's the whole login flow. Read the order of the checks, because the lockout demo later depends on it:
AuthService.login
@Transactional
public AuthResponse login(LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
if (!user.isAccountNonLocked()) {
throw new AccountLockedException("Account is locked. Please try again later.");
}
if (!user.isEnabled()) {
throw new EmailNotVerifiedException("Please verify your email before logging in.");
}
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
handleFailedLogin(user);
throw new AuthenticationException("Invalid credentials");
}
user.setFailedLoginAttempts(0);
user.setAccountLocked(false);
user.setLockExpiresAt(null);
user.setLastLoginAt(LocalDateTime.now());
userRepository.save(user);
return buildAuthResponse(user);
}
Unknown email, wrong password: both throw the same Invalid credentials message, so an attacker can't use the response to find out which emails are registered. The lock check comes before the password check. And a successful login resets the counters.
Log in and keep the tokens in shell variables for the later rounds:
RESP=$(curl -s -X POST $BASE/login \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}")
ACCESS=$(echo "$RESP" | jq -r .data.accessToken)
REFRESH_A=$(echo "$RESP" | jq -r .data.refreshToken)
The data object is the AuthResponse record: accessToken (a JWT), refreshToken (a random UUID stored in Postgres), tokenType (Bearer), expiresIn in seconds (900, from the fifteen-minute config) and a user object. Now call the protected route:
curl -s $BASE/me -H "Authorization: Bearer $ACCESS"
That returns the profile: username, email, role, no password and no lockout state. What matters is what happened before the controller ran. JwtAuthFilter from part 7 pulled the token out of the header and asked JwtService whether it was valid:
JwtAuthFilter.doFilterInternal (core)
final String jwt = authHeader.substring(7);
try {
final String userEmail = jwtService.extractUsername(jwt);
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
} catch (Exception e) {
log.debug("JWT processing failed: {}", e.getMessage());
}
JwtService.isTokenValid
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername())
&& !isTokenExpired(token)
&& !isTokenBlacklisted(token);
}
private boolean isTokenBlacklisted(String token) {
String jti = extractClaim(token, Claims::getId);
return Boolean.TRUE.equals(stringRedisTemplate.hasKey(BLACKLIST_PREFIX + jti));
}
Three checks: the subject matches, the token isn't expired, and its jti isn't in the Redis blacklist. If anything fails, the filter just doesn't set the SecurityContext, and JwtAuthEntryPoint turns the unauthenticated request into a 401 in our envelope.
Breaking it on purpose, round 1: the lockout
Two pieces of code make the lockout work. The first counts failures and sets the lock:
AuthService.handleFailedLogin
private void handleFailedLogin(User user) {
int attempts = user.getFailedLoginAttempts() + 1;
user.setFailedLoginAttempts(attempts);
if (attempts >= maxLoginAttempts) {
user.setAccountLocked(true);
user.setLockExpiresAt(LocalDateTime.now().plusMinutes(lockoutDurationMinutes));
log.warn("Account locked after {} failed attempts: {}", attempts, user.getEmail());
}
userRepository.save(user);
}
The second is on the entity. isAccountNonLocked() is where the lock expires, lazily, the next time someone asks:
User.isAccountNonLocked
@Override
public boolean isAccountNonLocked() {
if (!accountLocked) return true;
if (lockExpiresAt != null && LocalDateTime.now().isAfter(lockExpiresAt)) {
this.accountLocked = false;
this.failedLoginAttempts = 0;
this.lockExpiresAt = null;
return true;
}
return false;
}
The thresholds come from application.yml, not from the code:
application.yml
app:
security:
max-login-attempts: 5
lockout-duration-minutes: 30
Now send wrong passwords. Note the count: the fifth failure is the one that sets the lock, but that request still gets the normal 401. The first request that sees the lock is the sixth:
for i in {1..6}; do
curl -s -o /dev/null -w "%{http_code} " -X POST $BASE/login \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"WrongPass1!\"}"
done
# 401 401 401 401 401 423
The 423 carries the ACCOUNT_LOCKED error code from part 3's handler:
{
"success": false,
"message": "Account is locked. Please try again later.",
"errorCode": "ACCOUNT_LOCKED",
"timestamp": "2026-10-12T12:31:07.456"
}
Then try the correct password. It's still 423, because login() checks the lock before it ever looks at the password. If you want to see the state behind it, ask Postgres:
docker exec -it instagram-auth-postgres psql -U postgres -d instagram_auth \
-c "select email, failed_login_attempts, account_locked, lock_expires_at from users;"
Round 2: the rate limiter
The lockout protects one account. The rate limiter protects the endpoint, and it works per client IP. It only touches two routes:
RateLimitFilter.isRateLimitedEndpoint
private boolean isRateLimitedEndpoint(HttpServletRequest request) {
String path = request.getRequestURI();
String method = request.getMethod();
return "POST".equals(method) && (path.equals("/api/auth/login")
|| path.equals("/api/auth/forgot-password"));
}
Each IP gets a Bucket4j token bucket, created on first use and kept in a map. When the bucket is empty the filter writes the 429 itself, because it runs before Spring's exception handling ever gets involved:
RateLimitFilter.doFilterInternal (core)
String clientIp = getClientIp(request);
Bucket bucket = bucketCache.computeIfAbsent(clientIp, k -> createBucket());
if (bucket.tryConsume(1)) {
filterChain.doFilter(request, response);
} else {
log.warn("Rate limit exceeded for IP: {}", clientIp);
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(
objectMapper.writeValueAsString(
ApiResponse.error("Too many requests. Please try again later.", "RATE_LIMIT_EXCEEDED")
)
);
}
RateLimitFilter.createBucket
private Bucket createBucket() {
Bandwidth limit = Bandwidth.builder()
.capacity(capacity)
.refillIntervally(refillTokens, Duration.ofMinutes(refillMinutes))
.build();
return Bucket.builder().addLimit(limit).build();
}
application.yml
app:
rate-limit:
login-capacity: 10
login-refill-tokens: 10
login-refill-minutes: 1
Two practical notes before you run it. First, login and forgot-password share one bucket per IP, so the lockout round already spent some of your ten tokens; wait a minute for the refill. Second, use the correct password on a second, unlocked account. With a wrong one, the lockout would kick in after five tries and you'd see 423 before the limiter ever fires.
for i in {1..12}; do
curl -s -o /dev/null -w "%{http_code} " -X POST $BASE/login \
-H 'Content-Type: application/json' \
-d '{"email":"second@example.com","password":"YourRealPass1!"}'
done
# 200 200 200 200 200 200 200 200 200 200 429 429
Ten requests pass, then the bucket is empty and everything is rejected until it refills:
{
"success": false,
"message": "Too many requests. Please try again later.",
"errorCode": "RATE_LIMIT_EXCEEDED",
"timestamp": "2026-10-12T12:33:40.789"
}
Round 3: stealing a refresh token
This is the one I most wanted to show. Here's the whole refresh flow:
AuthService.refreshToken
@Transactional
public AuthResponse refreshToken(RefreshTokenRequest request) {
RefreshToken storedToken = refreshTokenRepository.findByToken(request.getRefreshToken())
.orElseThrow(() -> new InvalidTokenException("Invalid refresh token"));
if (storedToken.isRevoked() || storedToken.isExpired()) {
refreshTokenRepository.revokeAllUserTokens(storedToken.getUser());
throw new InvalidTokenException("Refresh token expired or revoked. Please log in again");
}
User user = storedToken.getUser();
storedToken.setRevoked(true);
refreshTokenRepository.save(storedToken);
return buildAuthResponse(user);
}
Normal use: the token is looked up, marked revoked, and a brand-new access and refresh token are issued. That's rotation, and it means every refresh token works exactly once. The interesting branch is the if. A token that is already revoked can only show up again if somebody kept a copy, so the service doesn't just reject it. It calls this:
RefreshTokenRepository.revokeAllUserTokens
@Modifying
@Query("UPDATE RefreshToken rt SET rt.revoked = true WHERE rt.user = :user")
void revokeAllUserTokens(User user);
One JPQL UPDATE marks every refresh token that user owns as revoked. Use a fresh account for this round, since the first one is locked, and log in to get token A. Then walk through the theft:
# 1. normal rotation: A is used up, B is issued
RESP2=$(curl -s -X POST $BASE/refresh \
-H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$REFRESH_A\"}")
REFRESH_B=$(echo "$RESP2" | jq -r .data.refreshToken)
# 2. the "thief" replays token A
curl -s -X POST $BASE/refresh \
-H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$REFRESH_A\"}"
# 3. the real user tries token B, which was valid a moment ago
curl -s -X POST $BASE/refresh \
-H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$REFRESH_B\"}"
Step 2 is rejected with a 401 and the INVALID_TOKEN code, and the message says Refresh token expired or revoked. Please log in again. Step 3 is the point of the whole design: token B was issued legitimately a moment ago, and it's rejected as well, because replaying A wiped every session that user had. The real user has to log in again. That's a deliberate trade. When an old token is replayed, one of the two people holding tokens isn't who they say they are, and the server can't tell which, so it ends both.
Password reset and logout
forgot-password always returns the same message, whether or not the email exists, so it can't be used to probe for accounts. It's rate-limited too, since it's the other endpoint attackers like. If the account exists, a reset code lands in MailHog, and reset-password takes that code plus a new password:
AuthService.resetPassword
@Transactional
public MessageResponse resetPassword(ResetPasswordRequest request) {
boolean valid = otpService.validateOtp(request.getEmail(), request.getOtp(), OtpService.OtpType.PASSWORD_RESET);
if (!valid) {
throw new InvalidOtpException("Invalid or expired OTP");
}
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new ResourceNotFoundException("User not found."));
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user);
refreshTokenRepository.revokeAllUserTokens(user);
return MessageResponse.builder()
.success(true)
.message("Password reset successful")
.build();
}
Look at the second-to-last line of logic: revokeAllUserTokens again. Someone resetting a password may be doing it because the account is compromised, so every existing refresh token dies with the old password.
Last, logout, and an immediate retry of /me with the same access token:
curl -s -X POST $BASE/logout -H "Authorization: Bearer $ACCESS"
curl -s -o /dev/null -w "%{http_code}\n" $BASE/me -H "Authorization: Bearer $ACCESS"
# 401
AuthService.logout
@Transactional
public MessageResponse logout(String accessToken, String email) {
jwtService.blacklistToken(accessToken);
userRepository.findByEmail(email).ifPresent(
user -> refreshTokenRepository.revokeAllUserTokens(user)
);
return MessageResponse.builder()
.success(true)
.message("Logged out successfully!!")
.build();
}
JwtService.blacklistToken
public void blacklistToken(String token) {
try {
String jti = extractClaim(token, Claims::getId);
long expiry = extractExpiration(token).getTime() - System.currentTimeMillis();
if (expiry > 0) {
stringRedisTemplate.opsForValue().set(
BLACKLIST_PREFIX + jti,
"revoked",
expiry,
TimeUnit.MILLISECONDS
);
}
} catch (Exception e) {
log.warn("Could not blacklist token: {}", e.getMessage());
}
}
An access token is a self-contained JWT, so the server can't "delete" it. Instead, logout stores its jti in Redis for exactly as long as the token has left to live. After that the token would be rejected for being expired anyway, so Redis cleans itself up. You can watch it:
docker exec -it instagram-auth-redis redis-cli --scan --pattern 'jwt:blacklist:*'
docker exec -it instagram-auth-redis redis-cli ttl "jwt:blacklist:<the-jti-from-above>"
What I'd still fix
Re-reading the code for this post turned up a few things I haven't fixed yet. I'd rather list them than pretend the repo is finished:
@Asyncdoes nothing.EmailService.sendOtpEmailis annotated@Async, but there's no@EnableAsyncanywhere in the project (only@EnableScheduling), so the email send blocks the request thread. It's a one-line fix on the main class.- The OTP is in the email subject. The subject is
"Instagram - Your verification code: " + otp, which shows up in lock-screen previews. The code belongs in the body only. - Too many OTP attempts isn't a typed exception.
OtpService.validateOtpthrows a plainRuntimeExceptionafter five bad guesses, so it falls through to the generic handler instead of returning a clean429. - The rate limiter is per instance, and it trusts
X-Forwarded-For. The buckets live in aConcurrentHashMap, so two instances behind a load balancer each keep their own count, and a client can send its ownX-Forwarded-Forheader unless a proxy in front overwrites it. For production, move the bucket state into Redis and only trust that header from your own proxy. - A naming nit.
RefreshTokenhas fields calledexpires_atandcreated_at, the only snake_case names in the codebase.
What the series was really about
None of the individual pieces are clever. A DTO validates input. A typed exception describes what went wrong. An entity carries its own rules. A thin controller routes. A filter turns a token into an identity. What makes it an authentication backend is that all of those decisions, made across ten parts, still agree with each other when you push on them.
We fixed real bugs on camera in almost every part, including a hardcoded localhost that stopped the app from reaching its own database inside Docker. I'd rather you saw that than a spotless first take, because that's what building software looks like.
Where to go from here
- Read or clone the code: the full repo, including a Postman collection for every endpoint above.
- Watch the whole series: the YouTube playlist, in order.
- Go deeper on OAuth2, MFA and deploying this properly: my Udemy course.
- Want a second pair of eyes on your own code or architecture: book a 1:1 on Topmate.
Thanks for following the whole build. If you made it to part 10, tell me in the comments which part you'd like me to go back and expand. Full source is in the GitHub repo.
Comments
Post a Comment