JWT auth in Spring Boot — lessons from a multi-team project
What we got wrong, what we got right, and what I'd do differently about authentication in Egalitarian — a waste management system built by four universities across three countries.
The Egalitarian project is an Erasmus+ collaboration between AAU Copenhagen, University of Brasilia, Saxion, and University of Minho. We’re building a waste management system for cooperatives in Brasilia — think gamified collection tracking, route optimization, and a points-based reward system for waste pickers. It’s the kind of project where you’re simultaneously navigating three timezones, two languages, and four very different approaches to software architecture.
I worked on the Spring Boot backend, and specifically owned the authentication layer. JWT auth in Spring Boot sounds straightforward — and it is, eventually — but there’s a lot of conceptual overhead before it clicks, especially if you haven’t worked with Spring Security before. Here’s what I wish I’d known.
The mental model that actually helped
Spring Security is a filter chain. Every request passes through a series of filters before it reaches your controllers. JWT authentication means adding one more filter that intercepts requests early, reads the token from the Authorization header, validates it, and — if valid — sets the authentication context so downstream filters and controllers know who the request belongs to.
Once you understand this, the configuration makes sense. You’re not “enabling JWT” in some magical way. You’re registering a custom filter, telling Spring to not manage sessions (since JWTs are stateless), and configuring which endpoints need authentication and which don’t. The boilerplate is verbose, but none of it is mysterious.
What we got wrong initially
Our first implementation stored the full user object in the JWT payload. This seems like a good idea (fewer database lookups!) until you realize that any change to a user’s role or status is invisible until the token expires. We had a moderator account that got its permissions changed, and the change didn’t take effect for twelve hours because the token was still valid.
The fix is to keep tokens lean — just a user ID and expiry — and look up the current user from the database on each request. Yes, this adds a DB query per request. For our scale, that’s fine. If you’re at a scale where it matters, you cache, but you don’t skip the lookup.
The other mistake was putting all security configuration in one giant SecurityConfig class that became a 300-line mess. When we finally refactored it into separate components — a JwtFilter, a JwtService, a UserDetailsService implementation, and a lean config class that wired them together — everything became much easier to reason about and test.
Refresh tokens: simpler than you think
We added refresh token support in the second semester. It sounds complicated but the pattern is simple: issue a long-lived refresh token stored in an HTTP-only cookie alongside the short-lived access JWT. When the access token expires, the client hits /auth/refresh, you validate the refresh token from the cookie, issue a new access JWT, and return it. The refresh token itself lives in the database so you can revoke it.
HTTP-only cookies for the refresh token is important — it means JavaScript on the client can’t read it, which protects against XSS. The access token can live in memory (not localStorage), which protects against the same thing for that token too.
Working with a multi-team, multi-country project taught me that the security decisions you make early are much harder to change than you think. Document your auth architecture clearly, agree on it explicitly with the other teams, and don’t let it evolve organically. We spent a sprint untangling assumptions that different teams had made independently about how tokens would be structured.