Spring Security Beyond the Login Form: Data Isolation and the 401/403 Contract
Why This Article Exists There is a very common kind of application: it lives inside a company network, it is reachable over the VPN, and it has been running like that for years. Accounting systems, internal reporting tools, warehouse apps, admin panels. Sometimes there is a login screen, sometimes
Why This Article Exists There is a very common kind of application: it lives inside a company network, it is reachable over the VPN, and it has been running like that for years. Accounting systems, internal reporting tools, warehouse apps, admin panels. Sometimes there is a login screen, sometimes there is not. It does not matter much, because the answer to "who can reach this?" is "whoever the network lets in". And then a day comes when that stops being enough. An accountant needs to close the month from home. An external auditor needs read access for two weeks. A partner wants to pull their own turnover figures. Someone builds a mobile client. The perimeter dissolves โ not because anyone decided to remove it, but because the business outgrew it. At that point the app has to start answering two questions on every single request that the network used to answer once, at the door: Who are you? โ no valid identity means 401. Are you allowed to do this, to this data? โ valid identity, insufficient rights, means 403. I want to show you how that is done, and I want to be straight about the stage it is done on. What follows is a demonstration project: a financial accounting system on Java 21 and Spring Boot 3.5. It is deployed and publicly reachable. There are no real customers and no real money in it, and every organization, counterparty and payment in the database is fabricated. The shape of that database is not. Organizations, counterparties, bank accounts, posted and reversed documents over an event-sourced ledger โ those are the structures that make authorization genuinely hard. A three-table toy makes it look easy. Security was also retrofitted here the way it usually is in real life: onto an application that already existed and had never been asked who was calling. That setup buys something a production write-up cannot. On a real system you get to describe a bug once, after it is fixed, with the details filed off. Here I can put the failure back in deliberately, show you the code that produces it, and then show you the fix โ on a system I am allowed to break in public. So none of the failures in this article are invented for teaching purposes. Every one of them is modelled on a real bug, from a real project, with real users โ most of them from a work project that ran in parallel with this one and wrapped up in May. What I could not do there was show you the code. So I rebuilt them here, on a codebase I am allowed to publish. This time you can follow the whole chain, from the first line to the fix. One last bit of honesty, this one about timing. I have wanted to write this up since that project ended, and it took until now. Part of that is the ordinary reason things get postponed. The better part is that every time I sat down to explain a piece of this, I found something in the piece still worth fixing. If you have a project you keep meaning to write about, that is my one argument for doing it: explaining your own code to a stranger is a code review you cannot talk your way out of. The interesting part, as it turns out, is not the authentication code โ that is well documented and mostly mechanical. It is everything the perimeter had been quietly covering up. Full code is available on GitHub. Target audience: This article is aimed at beginner and mid-level developers who are adding Spring Security to an application that already exists. If you have wired up a login form before but never had to answer "whose rows are these?" โ this article is for you. Here is the thing nobody tells you when you add spring-boot-starter-security. Inside a VPN, every service method was written under one unstated assumption: whoever is calling has the right to see this. So getAllBankAccounts() returns all bank accounts. findByBankId(bankId) returns every account at that bank. Nobody ever wrote WHERE organization_id = ?, because there was nothing to defend against โ everyone on the network was, by definition, a colleague. Publish that same service behind a login form and every one of those methods becomes an IDOR (Insecure Direct Object Reference). A perfectly valid, correctly authenticated user of organization A now calls a perfectly legitimate endpoint and reads organization B's payment history. No exploit, no clever attack. Just a GET with a valid token. Adding a login screen does not fix this. It gives every attacker a legitimate identity to attack with. So the work splits into two very different halves: Authentication and endpoint rules โ largely a configuration problem. A weekend. Data isolation in the service layer โ a design problem that touches every read path in the codebase. Most guides cover the first half. The second half is where the real bugs live. The app is a REST API with a separate frontend, so sessions were not an option: JWT with short-lived access tokens (15 minutes) and long-lived refresh tokens (7 days). Signing them, validating them, wiring up the filter โ that is the well-documented part, and I am going to skip most of it. One decision here is worth the whole section, because the entire second half of the work leans on it: the caller's organization travels inside the identity. One claim when the token is minted: if (user.getOrganization() != null) { builder.claim("orgId", user.getOrganization().getId()); } and a principal that is a record rather than a String username: public record JwtPrincipal(Long userId, String email, UserRole role, Long organizationId) { } View JwtService.java on GitHub ยท View JwtPrincipal.java on GitHub Because tenancy rides along with the identity, any service can ask "which organization is calling?" without a database round trip and without threading a parameter through fifteen method signatures. Pass it as a method parameter instead and you have just created fifteen places to forget it. The filter that turns a token into that principal is unremarkable, except for what it checks besides the signature โ a valid signature is necessary, not sufficient: String jti = claims.getId(); if (jti != null && tokenBlacklistService.isBlacklisted(jti)) { filterChain.doFilter(request, response); // logged out โ stays anonymous return; } Optional<JwtPrincipal> optionalPrincipal = toPrincipal(claims); if (optionalPrincipal.isEmpty()) { filterChain.doFilter(request, response); // claims we can no longer read return; } JwtPrincipal principal = optionalPrincipal.get(); if (userRevocationService.isRevoked(principal.userId())) { filterChain.doFilter(request, response); // deactivated by an admin return; } One habit worth stealing: the filter never throws. If the token is missing, malformed, expired, blacklisted or belongs to a revoked user, it simply does not authenticate and lets the chain continue. The request then hits the authorization rules as anonymous, and Spring's AuthenticationEntryPoint produces one consistent 401. One code path, one response shape, no leaking of why the token was rejected. That habit has to cover your own parsing too. The claims are ones you wrote, so they look safe. But Long.parseLong and UserRole.valueOf still throw on a token that was minted before you renamed something, and a throw here lands in the catch-all handler โ which is the exact failure the last section of this article is about: private Optional<JwtPrincipal> toPrincipal(Claims claims) { try { return Optional.of(new JwtPrincipal( Long.parseLong(claims.getSubject()), claims.get("email", String.class), UserRole.valueOf(claims.get("role", String.class)), claims.get("orgId", Long.class))); } catch (IllegalArgumentException | NullPointerException | JwtException e) { return Optional.empty(); } } View JwtAuthenticationFilter.java on GitHub The natural first implementation returns both tokens in the JSON body. It works, it demos well, and it means any XSS on your frontend hands the attacker seven days of access instead of fifteen minutes. The refresh token never appears in a response body. It is an HttpOnly cookie, scoped to exactly one path: private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN_COOKIE, refreshToken) .httpOnly(true) .secure(true) .path("/api/auth/refresh") // sent to this endpoint and nowhere else .maxAge(Duration.ofMillis(jwtService.getRefreshTokenExpiration())) .sameSite("Strict") .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } Refresh tokens are stored hashed (SHA-256) and rotated on every use: the old one is marked used and revoked, a new pair is issued. That gives you theft detection for free โ if a token that has already been used shows up again, either it was stolen or it was replayed, and in both cases the honest answer is to burn the whole family: if (refreshToken.isUsed()) { refreshTokenRepository.revokeAllByUserId(refreshToken.getUser().getId()); log.warn("SECURITY: Refresh token reuse detected for userId={}. All tokens revoked.", refreshToken.getUser().getId()); eventPublisher.publishEvent(SecurityAuditEvent.tokenReuseDetected(...)); throw new InvalidTokenException("Token reuse detected"); } View AuthController.java on GitHub ยท View AuthService.java on GitHub The trap that cost me an evening: cookies plus CORS. The browser will neither store nor send that cookie unless every auth call uses credentials: 'include', and the server sets allowCredentials(true) with an explicit origin list โ a wildcard origin is rejected outright. It gets worse if a client tries to be helpful and sends the refresh token in a custom X-Refresh-Token header instead. That header is not on the allowed list, so the preflight fails. The response comes back without an Access-Control-Allow-Origin header, and the browser reports a missing origin error. You then spend an hour debugging CORS configuration for what is really a client sending the wrong thing. Two filter chains: one for Swagger and health checks, one for everything else. The @Order(1) chain uses securityMatcher to claim the documentation paths, so the main chain never sees them. The main chain is ordinary Spring Security, with one habit worth adopting โ write rules from most specific to least specific, and end with a default that fails closed: .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/logout").authenticated() .requestMatchers("/api/auth/change-password").authenticated() .requestMatchers("/api/auth/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/exchange-rates/latest/{date}").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") // master data: read for everyone authenticated, writes for admins .requestMatchers(HttpMethod.POST, "/api/banks/**", "/api/currencies/**", "/api/countries/**", "/api/exchange-rates/**").hasRole("ADMIN") // ... PUT / PATCH / DELETE likewise // operational default .requestMatchers(HttpMethod.GET, "/api/**").authenticated() .requestMatchers(HttpMethod.POST, "/api/**").hasAnyRole("USER", "ADMIN") .requestMatchers(HttpMethod.PUT, "/api/**").hasAnyRole("USER", "ADMIN") .requestMatchers(HttpMethod.PATCH, "/api/**").hasAnyRole("USER", "ADMIN") .requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN") .anyRequest().authenticated() ) Note that /api/auth/logout is declared before /api/auth/**. Rules are evaluated in order and the first match wins; flip those two lines and logout becomes a public endpoint. This is the single easiest way to open a hole in an otherwise correct configuration, and nothing warns you. The catch-all .anyRequest().authenticated() at the end means a new controller added six months from now is protected by default. Every rule set should end with the restrictive case, not the permissive one. View SecurityConfig.java on GitHub Endpoint rules say this role may call this URL. They say nothing about whose rows come back. A USER with a perfectly valid token calling a perfectly legitimate GET /api/bank-accounts is exactly the scenario the VPN used to make impossible. Concretely: behind that endpoint the service was a single line โ bankAccountRepository.findAllWithRelations() โ and that method meant all of them. Log in as a USER of organization 1, click the button that user is supposed to click, and organization 2's accounts come back in the same list. Revert the change and you can reproduce that in this demo today. I have also watched almost exactly this finding land in a real security review. What makes it dangerous is how ordinary it looks. Nothing in the request is wrong. Nothing in the access log looks wrong. There is no failed login to alert on, no rate limit to trip, no stack trace. The only thing that changed is that the network is no longer the one deciding who "we" means โ and no part of the application noticed, because no part of the application was ever told. The fix is a small component that reads the organization off the principal and turns it into an authorization decision: @Component public class OrganizationSecurityContext { public Long getActiveOrganizationId() { JwtPrincipal principal = getCurrentPrincipal(); Long organizationId = principal.organizationId(); if (organizationId == null) { throw new AccessDeniedException("No active organization for current user"); } return organizationId; } public void validateAccess(Long organizationId) { if (organizationId == null) { throw new AccessDeniedException("Organization id is required"); } JwtPrincipal principal = getCurrentPrincipal(); if (principal.role() == UserRole.ADMIN) { return; } if (!organizationId.equals(principal.organizationId())) { throw new AccessDeniedException("User does not have access to organization " + organizationId); } } } View OrganizationSecurityContext.java on GitHub Two access patterns cover almost everything: Reads get filtered. The service resolves a scope and passes it into the query. null means "admin, no filter": private Long queryOrgScope() { return orgContext.isAdmin() ? null : orgContext.getActiveOrganizationId(); } public List<BankAccountResponseDto> getAllBankAccounts() { List<BankAccount> bankAccounts = bankAccountRepository.findAllWithRelations(queryOrgScope()); return bankAccountMapper.toResponseList(bankAccounts); } @Query("SELECT ba FROM BankAccount ba " + "LEFT JOIN FETCH ba.bank " + "LEFT JOIN FETCH ba.currency " + "WHERE (:orgId IS NULL " + " OR ba.holderType = 'COUNTERPARTY' " + " OR (ba.holderType = 'ORGANIZATION' AND ba.holderId = :orgId))") List<BankAccount> findAllWithRelations(@Param("orgId") Long orgId); View BankAccountService.java on GitHub ยท View BankAccountRepository.java on GitHub Single-entity fetches get validated. You cannot filter a findById, so you load and then check ownership before mapping โ and importantly, before the entity reaches a DTO: public BankAccountResponseDto getBankAccountById(Long id) { BankAccount bankAccount = bankAccountRepository.findByIdWithRelations(id) .orElseThrow(() -> BankAccountNotFoundException.byId(id)); validateAccountAccess(bankAccount); return bankAccountMapper.toResponse(bankAccount); } And this is the test that closes it โ the one that fails against every version of this system that shipped behind the VPN: @Test void validateAccess_WhenForeignOrganization_ShouldThrow() { authenticateAs(UserRole.USER, ORG_ID); AccessDeniedException ex = assertThrows(AccessDeniedException.class, () -> securityContext.validateAccess(OTHER_ORG_ID)); assertEquals("User does not have access to organization 2", ex.getMessage()); } View tests: OrganizationSecurityContextTest.java JwtAuthenticationFilterTest.java Some notes from doing this across nine services: The filter belongs in the query, not in a Java stream().filter(). Filtering after the fact still pulls the other organization's rows across the wire and into memory, and pagination silently breaks: page 1 of 20 rows returns 3 results. Not everything should be scoped, and that is a decision worth writing down. Counterparties in this system are a deliberately shared directory โ the same supplier is invoiced by several organizations, and duplicating them per organization would fragment payment history. Visibility comes from the documents that reference a counterparty, which are scoped. Exchange rates, banks, currencies and countries are global reference data. Every "global" decision should be a recorded decision, not an oversight that happens to look like one. Admin bypass needs to be explicit and centralized. One isAdmin() check inside the security context, not if (role == ADMIN) sprinkled through nine services. Writes need the same treatment as reads, including the "which org does this new row belong to" question. A non-admin does not get to choose: if (requestDTO.getHolderType() == AccountHolderType.ORGANIZATION) { requestDTO.setHolderId(orgContext.resolveOrganizationId(requestDTO.getHolderId())); } Get them wrong and the app develops symptoms that look exactly like a permissions bug. This is my favourite part, because it is the one I did not see coming. Once your API is public, HTTP status codes stop being cosmetic. They are the protocol your client uses to decide what to do next: 401 โ no valid identity. The client should try to refresh, and if that fails, send the user to the login screen. 403 โ valid identity, insufficient rights. The client should show a message. Refreshing will not help; logging out and back in will not help. Spring Security handles the framework-level cases through AuthenticationEntryPoint (401) and AccessDeniedHandler (403). What it does not cover is anything thrown after the filter chain has already let the request through. That is exactly where the interesting denials live. Bug one: AccessDeniedException from the service layer became a 500. The endpoint rules passed (a USER is allowed to call GET /api/bank-accounts/42), the request reached the controller, and only then did validateAccess throw. By then the security filter chain is long gone. The exception surfaces as an ordinary controller exception and falls through to the catch-all @ExceptionHandler(Exception.class), which maps everything to 500 and helpfully includes the internal message. A cross-organization access attempt was reported as a server error. Bug two, the expensive one: a missing refresh cookie became a 500. When an access token expired, the frontend called /api/auth/refresh. If the cookie was gone too, Spring raised MissingRequestCookieException โ which, again, hit the catch-all and came back as 500. The frontend did not recognize a 500 as "your session ended", so it silently cleared its tokens and kept going. What lands on screen is "insufficient permissions" โ on pages that worked a minute ago. I reproduced that here because I had seen it before, somewhere with rather more at stake. There the person reading that message was a real user, mid-task, on screens that had worked all morning. The ticket they filed said "permissions are broken". Every visible symptom pointed at authorization. The cause was three levels away, in the catch-all exception handler โ the one piece of the stack nobody revisits when they add security, because it predates the security layer and looks like it has nothing to do with it. The fix is boring, which is the point. Handle the security exceptions explicitly, in a @RestControllerAdvice ordered ahead of the generic one: @RestControllerAdvice @Order(1) public class SecurityExceptionHandler { /** * Handles denials raised inside the service layer (OrganizationSecurityContext). * Denials at the filter chain level are handled by CustomAccessDeniedHandler instead. */ @ExceptionHandler(AccessDeniedException.class) public ResponseEntity<ErrorResponse> handleAccessDeniedException( AccessDeniedException ex, HttpServletRequest request) { return buildErrorResponse(HttpStatus.FORBIDDEN, ex.getMessage(), request); } @ExceptionHandler(InvalidTokenException.class) public ResponseEntity<ErrorResponse> handleInvalidTokenException( InvalidTokenException ex, HttpServletRequest request) { return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage(), request); } } And take the cookie yourself instead of letting the framework reject the request for you: @PostMapping("/refresh") public ResponseEntity<AccessTokenResponse> refresh( @CookieValue(name = REFRESH_TOKEN_COOKIE, required = false) String refreshToken, HttpServletResponse response) { if (refreshToken == null || refreshToken.isBlank()) { throw new InvalidTokenException("Refresh token is missing"); // โ 401, not 400 or 500 } ... } View SecurityExceptionHandler.java on GitHub ยท View AuthController.java on GitHub The rule I would put on the wall: no framework exception should ever reach your catch-all handler. That handler exists to keep the process alive, not to answer clients. Anything it catches is, by definition, a status code you never designed โ and on a public API, an undesigned status code is a bug in someone else's application. Related, and cheap: turn off stack traces in fallback error responses. The default /error output leaks your package structure and library versions to anyone with curl. Once identity works, a handful of controls turn a login form into something you can leave facing the internet. None of them are complicated: Account lockout โ 5 failed attempts, 30-minute lock, with the row read via SELECT ... FOR UPDATE so parallel attempts cannot race past the counter. Rate limiting โ Resilience4j, 5 requests per 60 seconds on the auth endpoints, returning 429. This is what stands between you and credential stuffing. Token blacklist โ a JWT is valid until it expires, so logout needs somewhere to record "this jti is dead". Caffeine cache (15-minute TTL, matching the access-token lifetime) in front of a database table, so a restart does not resurrect logged-out tokens. User revocation โ checked in the filter, so deactivating an account takes effect on the next request, not in fifteen minutes. Security audit events โ login success and failure, lockouts, token refresh, token reuse, all published as Spring application events and persisted. The first time you need to answer "when exactly did this account get locked, and from which IP", you will be glad it is a table and not a log file. Security headers โ HSTS, X-Content-Type-Options, frame-ancestors 'none', a restrictive CSP. Four lines in the config. .requestMatchers("/api/auth/**").permitAll() placed above the logout rule makes logout public. The first matching rule wins, and nothing warns you โ not a startup error, not a log line. Write rules from most specific to least specific and end with .anyRequest().authenticated(). The browser drops the refresh cookie unless every auth call uses credentials: 'include' and the server sets allowCredentials(true) with an explicit origin list. Send the token in a custom header instead and the preflight fails with no Access-Control-Allow-Origin, which the browser reports as a missing origin. The config is fine. The client is sending the wrong thing. stream().filter(...) after the repository call still moves another organization's rows across the wire, and pagination breaks quietly: page 1 of 20 rows comes back with 3 results. The organization id belongs in the query. @ExceptionHandler(Exception.class) was written before there was anything to deny. After you add security it starts catching AccessDeniedException and MissingRequestCookieException and turning both into 500. Nobody revisits that class when adding security, because it looks unrelated. Long.parseLong(claims.getSubject()) and UserRole.valueOf(...) look safe, because you wrote those claims yourself. Rename an enum constant and every token minted in the last fifteen minutes throws inside the filter โ straight into pitfall 4. Request with Bearer token โ โผ JwtAuthenticationFilter โ โโโ no token / bad signature / expired โโโโ โโโ jti blacklisted (logged out) โโโโโโโโโโค โโโ user revoked by an admin โโโโโโโโโโโโโโคโโโบ never authenticated โโโ claims cannot be parsed โโโโโโโโโโโโโโโ โ โ โผ โผ JwtPrincipal(userId, email, role, orgId) JwtAuthenticationEntryPoint โ 401 โ SecurityFilterChain rules โ โโโ role not allowed โโโบ CustomAccessDeniedHandler โ 403 โผ Controller โ Service โ โโโ OrganizationSecurityContext.validateAccess(orgId) โ โโโ foreign organization โโโบ AccessDeniedException โ โโโ SecurityExceptionHandler โ 403 โผ Repository query filtered by :orgId โโโบ 200, this organization's rows only If you are about to take an internal application public, in this order: Inventory every read path and ask who is allowed to see those rows. This is the long pole, not the login form. Start here, not last. Put tenancy in the principal, not in method parameters. It travels for free and cannot be forgotten at a call site. Filter in the query. In-memory filtering breaks pagination and still moves the data. Write down what is deliberately global โ shared directories, reference data. Undocumented exceptions are indistinguishable from bugs during the next review. Design your 401 vs 403 contract before writing clients, and make sure nothing framework-thrown can bypass it into a 500. End your authorization rules with the restrictive case, so future endpoints are protected by default. Test the negative paths. "User from org A gets 403 for org B's account" is the test that actually proves the work; "user can read their own data" passes just as happily on a completely broken system. The authentication part of this was a weekend. The data isolation was the real project โ and it was the only part that would have shown up in a breach report. The system described here is a financial accounting prototype: Java 21, Spring Boot 3.5, PostgreSQL, event-sourced banking documents. The data in it is fabricated, the structure is not. It is deployed, so you can try the 401 / 403 behaviour above instead of taking my word for it: curl -i https://api.tarasantoniuk.com/api/countries # HTTP/2 401 # {"status":401,"error":"Unauthorized","message":"Authentication is required to access this resource", ...} Demo UI: https://finance.tarasantoniuk.com API: https://api.tarasantoniuk.com Swagger: https://api.tarasantoniuk.com/swagger-ui/index.html Source: https://github.com/TarasAntoniuk/finance Registration is open. A new account is created as a GUEST in the demo organization, so it can read but not write. A 403 on any POST is the endpoint rules working, not a broken demo. Happy to hear how others handled the service-layer half of this โ especially anyone who went the Hibernate filters or row-level security route instead of explicit query parameters. Full project code on GitHub Spring Security Documentation OWASP: Broken Access Control OWASP Cheat Sheet: Authorization OWASP Cheat Sheet: REST Security About the author: Connect: LinkedIn GitHub HackerRank Personal Website If you found this article helpful, please leave a reaction โค๏ธ and follow for more!
Key Takeaways
- โขWhy This Article Exists There is a very common kind of application: it lives inside a company network, it is reachable over the VPN, and it has been running like that for years
- โขThis story was reported by Dev.to, covering developments in the dev space.
- โขAI advancements continue to reshape industries โ read the full article on Dev.to for complete coverage.
๐ Continue reading the full article:
Read Full Article on Dev.to โShare this article



