When a 403 Wasn’t Really a Security Problem
Debugging a Dockerized Spring Boot Registration Failure
A practical debugging journey through Angular, Spring Cloud Gateway, Spring Security, Thymeleaf and Docker Compose.
The symptom: registration returned 403
While working on Veendibazaar, my personal microservices e-commerce application, I found that login was working through the Angular UI, but registration was failing.
POST http://localhost:9090/api/v1/auth/register
403 Forbidden
The browser’s message naturally pointed me toward Spring Security. A POST request returning 403 often makes security configuration the first place to investigate. But an HTTP status is a symptom, not necessarily the complete explanation.
The request path
The application uses Angular for the UI, Spring Cloud Gateway as the entry point, and a Spring Boot Authentication Service. The Auth Service also uses PostgreSQL and Kafka, while MailDev captures email during local development.
Angular UI :4200
↓
API Gateway :9090
↓
Auth Service :8081
↓
Registration logic → Thymeleaf email template → MailDev
First check: Spring Security
I inspected the Auth Service security configuration. CSRF was disabled, and the authentication endpoints were explicitly permitted:
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(req -> req
.requestMatchers(“/api/v1/auth/**”, “/auth/**”).permitAll()
.anyRequest().authenticated()
)
That made a simple authorization-rule explanation less likely. I still needed to check whether the custom JWT filter was interfering with the request.
Second check: the JWT filter
The custom filter explicitly bypassed authentication endpoints:
if (request.getServletPath().startsWith(“/api/v1/auth”)
|| request.getServletPath().startsWith(“/auth”)) {
filterChain.doFilter(request, response);
return;
}
This indicated that the registration endpoint should pass through the JWT filter without requiring a token. Rather than changing security code speculatively, I moved to the Auth Service logs.
The turning point: backend logs
The logs revealed a Thymeleaf TemplateInputException. The relevant stack trace led through EmailService.sendEmail(), AuthenticationService.sendValidationEmail(), AuthenticationService.register(), and AuthenticationController.register().
TemplateInputException:
Error resolving template [ACTIVATE_ACCOUNT],
template might not exist or might not be accessible
This was the key discovery: the registration flow had reached the business logic and failed while preparing the activation email. The browser showed a 403, while the backend logs exposed a template-resolution exception. That difference was a reminder to inspect both the HTTP response and the server-side exception rather than relying on the status code alone.
The template existed — so why could Thymeleaf not find it?
The resource directory contained this file:
src/main/resources/templates/activate_account.html
The file existed, but the exception showed that Thymeleaf was being asked to resolve ACTIVATE_ACCOUNT. That mismatch led me to the enum and the code that selected the template name.
The subtle Java enum mistake
The enum was designed to associate a constant with a template filename:
@Getter
public enum EmailTemplateName {
ACTIVATE_ACCOUNT(“activate_account”);
private final String name;
EmailTemplateName(String name) {
this.name = name;
}
}
The EmailService, however, used the enum’s built-in name() method:
Get Indranil Chakraborty’s stories in your inbox
Join Medium for free to get updates from this writer.
templateName = emailTemplate.name();
In Java, Enum.name() returns the declared enum constant name. For this constant, it returns ACTIVATE_ACCOUNT — not the custom field value activate_account. Lombok’s @Getter provides getName() for the custom field.
The one-line fix
I changed the template selection from:
templateName = emailTemplate.name();
to:
templateName = emailTemplate.getName();
Now the value passed to Thymeleaf is activate_account, which matches activate_account.html. The enum itself did not need to change.
Rebuild and recreate the Docker container
A source-code edit does not update a JAR already running inside a container. After rebuilding the Auth Service image, I recreated only that service rather than taking down the entire Compose environment:
docker compose up -d — force-recreate auth-service
This allowed PostgreSQL, Kafka, MailDev and the API Gateway to remain running while the Auth Service started with the updated image.
The end-to-end retest
I then retried registration from the Angular UI. Registration worked, and the activation-email flow completed successfully.
Angular → API Gateway → Auth Service
→ EmailService → Thymeleaf template
→ MailDev
That confirmed the fix in the actual Dockerized environment, not just in the IDE.
Lessons from the debugging session
1. Don’t stop at the HTTP status
A 403 can point you toward security, but it does not explain every failure in the request path. Check the backend logs and identify the first component that fails.
2. Trace the request end to end
Follow the request from the browser through the gateway, controller, service layer and any downstream component involved in the operation.
3. Know the difference between Enum.name() and a custom field
Enum.name() returns the enum constant’s declared name. A custom value such as “activate_account” must be read through its getter or another explicitly designed accessor.
4. Treat Docker as a useful test environment
Linux containers can expose assumptions that are easy to overlook on a Windows development machine, including filename case sensitivity. In this incident, though, the direct cause was the wrong Java accessor — not Docker itself.
5. Make one change at a time
Changing Spring Security, JWT handling, CORS, Gateway routing and Docker networking all at once would have made the result harder to diagnose. A disciplined sequence of checks narrowed the problem to one line of code.
Final takeaway
The most valuable part of this exercise was not the one-line fix. It was asking, “Where did the request actually fail?” instead of assuming the browser’s status code told the whole story.
In a microservices application, the browser is often only the beginning of the investigation. Trace the request through each layer, read the logs, and fix the first confirmed failure.
Sometimes the most misleading distributed-system failures have a one-line root cause.







