Part 8 of Building Instagram's Authentication Backend. If you're just joining, the repo is here and you can catch the earlier parts on the YouTube playlist.
We built an engine with no ignition
Here's where we actually stood after seven parts of this series. We had a fully working AuthService — register, login, refresh, the works. We had a security layer sitting on top of it, filtering every request, deciding who's allowed where. We had entities, repositories, DTOs, a whole exception-handling backbone.
And none of it had a URL.
Not one line of that code could be reached from outside the JVM. No curl command, no Postman request, nothing. It's a strange feeling to have written that much working logic and still not be able to actually call any of it. So that's what this part is about — wiring up the front door.
The controller, and why it's boring on purpose
Here's the register endpoint, in full:
@PostMapping("/register")
@Operation(summary = "Register a new user")
public ResponseEntity<ApiResponse<MessageResponse>> register(
@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.ok("Registration successful", authService.register(request)));
}
That's it. Four lines that matter. And nine of the ten endpoints in AuthController look almost exactly like this one — same shape, different DTO, different service method. I used to think that repetition was a sign I hadn't abstracted enough. I don't think that anymore.
The controller isn't supposed to be interesting. @Valid triggers the validation rules we wrote back in part 4. If something fails, it throws, and the handler we built in part 3 catches it and shapes the response. The actual work — hashing the password, checking for duplicate emails, sending the OTP — all of that already happened, two parts ago, inside AuthService. The controller's whole job is routing and shape. Nothing else.
Once you see it that way, a thin controller stops feeling like a limitation and starts feeling like the correct amount of code for the job.
The one line that made the security episode click
This is the bit I actually enjoyed writing. Here's logout:
@PostMapping("/logout")
public ResponseEntity<ApiResponse<MessageResponse>> logout(
@RequestHeader("Authorization") String authHeader,
@AuthenticationPrincipal UserDetails currentUser) {
String token = authHeader.substring(7);
return ResponseEntity.ok(ApiResponse.ok(authService.logout(token, currentUser.getUsername())));
}
See that @AuthenticationPrincipal UserDetails currentUser parameter? There's no lookup, no manual token parsing, no "let me grab the header and figure out who this is." I just ask for the current user, and Spring hands them to me.
That only works because of the filter we built in part 7 — JwtAuthFilter reads the token before the request ever reaches this method, and sets it on the SecurityContext. By the time logout runs, the identity question is already answered. Writing that filter three parts ago felt a little abstract at the time. Seeing it pay off here, as a single method parameter, was genuinely satisfying — that's the moment the security layer stopped being theoretical and started being useful.
A typo I'd been shipping for weeks
While I was wiring up OpenApiConfig for Swagger, I actually caught something embarrassing. Here's the description string I'd written, apparently a while ago and never looked at again:
.description("Production-ready Instagram Login — JWT + Spring Security 6")
Spring Security 6. We've been on Spring Security 7 since part one. Nobody's going to get hurt by a wrong number in an API description, but it bugged me — that's the kind of small lie that erodes trust in your own docs. If the description page is wrong, what else is stale? Fixed it to 7, moved on. Small thing, but worth calling out, because I bet it's not the only doc-string like this hiding in your own projects either.
The scheduler that works, and the async that doesn't
The other config class this episode is TokenCleanupScheduler — a small job that runs at 2 AM and deletes expired or revoked refresh tokens so that table doesn't grow forever:
@Scheduled(cron = "0 0 2 * * *")
@Transactional
public void cleanExpiredTokens() {
refreshTokenRepository.deleteExpiredAndRevokedTokens(LocalDateTime.now());
}
2 AM isn't arbitrary — it's just whenever your traffic is lowest, so a bulk delete isn't fighting real users for locks on that table. Adjust it to your own quiet hours.
But here's the thing that actually surprised me while I was double-checking this. @Scheduled only does anything if @EnableScheduling is present somewhere in your app. I went and checked the main class:
@SpringBootApplication
@EnableScheduling
public class InstaAuthServiceApplication { ... }
Good — it's there. The cleanup job actually runs.
Then I remembered something from a couple episodes back. Our EmailService sends OTP emails with @Async, so registration doesn't sit there waiting on an SMTP round trip. At the time I said "verify this is actually enabled" and moved on. So I went looking for @EnableAsync anywhere in the codebase.
It's not there. Not on the main class, not anywhere.
Which means, right now, every @Async annotation on sendOtpEmail is a no-op. The method looks async. It isn't. Every email send is quietly blocking the request thread, exactly like it would if I'd never written @Async at all.
I actually like finding bugs like this on camera, if I'm honest — it's a good reminder that two annotations that look almost identical (@EnableScheduling, @EnableAsync) can have completely different fates in the same codebase, and the only way to know is to actually check, not assume. The fix is one line. I'll add it before the next part.
Try it yourself
Once the app is running (which, spoiler, is literally next episode), here's what hitting this controller actually looks like from the outside:
# register a user
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "prashant",
"email": "you@example.com",
"password": "SecurePass1!",
"fullName": "Prashant Sharma"
}'
# then call a protected route with the token you get back
curl http://localhost:8080/api/auth/me \
-H "Authorization: Bearer <your-access-token>"
Or, once Swagger's wired up properly with the bearer scheme from OpenApiConfig, you can do the same thing by pasting your token into the "Authorize" button and clicking through the docs instead. Both work. I'll show the Swagger version on screen next time.
What's actually next
Every layer of this application exists now. DTOs, exceptions, entities, repositories, five services, a security layer, and — as of today — a controller that exposes all of it over HTTP. On paper, this thing is done.
Except I still haven't run it. Not once, this whole series. Every part so far has been code that should work, reasoned about carefully, tested only by reading it. Next part, we containerize it — Postgres, Redis, the app itself — and actually hit run for the first time.
I'm genuinely a little nervous about it, which is probably a good sign that I haven't been faking my way through this build.
Full source for this part is in the GitHub repo. If you spot the @EnableAsync gap in the code before I fix it in the next commit, that's a good sign you're reading closely — tell me in the comments.
Comments
Post a Comment