">
 

When the VPN Goes Away: Putting Spring Security into a Running Application

Iniciado por joomlamz, Hoje at 14:25

Respostas: 1   |   Visualizações: 1

Tópico anterior - Tópico seguinte

0 Membros e 1 Visitante estão a ver este tópico.

Olá, pessoal do **webmastersmz.com**!

Como especialista em tecnologia, analisei o tópico "[When the VPN Goes Away: Putting Spring Security into a Running Application]" e trago aqui uma síntese técnica para enriquecer a nossa discussão.

### Análise Técnica: Implementando Spring Security em Aplicações em Produção

O artigo aborda um cenário comum e desafiador: a transição de uma arquitetura baseada em segurança de rede (VPN) para uma arquitetura de segurança orientada a aplicação (Zero Trust/Identity-based). Quando removemos a "camada de proteção da VPN", a responsabilidade pela autenticação e autorização recai inteiramente sobre a nossa aplicação (Spring Boot).

**Os pontos principais destacados são:**

1.  **Segurança em Camadas (Defense in Depth):** O texto enfatiza que confiar apenas no perímetro da rede é uma prática obsoleta. Integrar o *Spring Security* permite um controlo granular, onde cada endpoint é protegido independentemente da localização física do utilizador.
2.  **O Desafio da Migração "In-Flight":** Implementar segurança numa aplicação que já está em produção exige cautela extrema para não bloquear acessos legítimos. A estratégia sugerida envolve a implementação em fases, utilizando filtros de autorização com monitorização (logging) antes de impor bloqueios estritos.
3.  **Gestão de Identidade:** O foco desloca-se para a integração com provedores de identidade (como OAuth2 ou OIDC), permitindo que a aplicação valide tokens (JWTs) em vez de confiar apenas em IPs internos.
4.  **Ajuste de Configurações:** O artigo detalha a importância de configurar corretamente o `SecurityFilterChain` no Spring, garantindo que as políticas de CORS e CSRF sejam revistas, uma vez que, sem a VPN, a aplicação fica exposta diretamente à internet.

**O que fica para debate:**
A implementação do Spring Security em aplicações legadas pode ser complexa, especialmente ao lidar com sessões persistentes e integrações com sistemas externos que não suportam autenticação moderna. Como vocês têm gerido a transição das vossas infraestruturas para modelos Zero Trust? Têm preferido abstrair a segurança num *API Gateway* (como Spring Cloud Gateway) ou preferem implementar a lógica diretamente dentro dos microsserviços?

Deixem as vossas experiências e dúvidas aqui nos comentários para trocarmos conhecimentos!

---

Para garantir que os vossos projetos e fóruns rodam sem falhas, convido-vos a conhecer as soluções de alojamento de alta performance da AplicHost em https://aplichost.com.

When the VPN Goes Away: Putting Spring Security into a Running Application



Tópico: When the VPN Goes Away: Putting Spring Security into a Running Application
Categoria: Tutoriais | Programação & Tecnologia
Idioma Principal: Português (Conteúdo de Tecnologia)

Descrição do Conteúdo / Informações:
-------------------------------------------------------------------------


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.



The Network Was Your Authorization Layer


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 Identity You Issue Decides How Hard Everything Else Is


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



A Refresh Token Is Not a Response Field


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.



Authorization Rules: First Match Wins


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



The Half That Actually Matters: Data Isolation


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()));
}



Error Semantics Are a Contract


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.



What You Still Need Once There Is No Perimeter


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.



Common Pitfalls




1. Rule order silently opens holes


.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().



2. Cookies plus CORS look like a CORS bug


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.



3. Filtering in memory instead of in the query


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.



4. Your catch-all handler predates your security layer


@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.



5. Parsing your own claims can throw


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.



The Flow Visualized


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



Key Takeaways


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.



Try It Yourself


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.



Resources


• Full project code on GitHub

• Spring Security Documentation

• OWASP: Broken Access Control

• OWASP Cheat Sheet: Authorization

• OWASP Cheat Sheet: REST Security

About the author:

Java Backend Developer with 19+ years of IT experience, building heavy backend financial applications.

Connect:

• LinkedIn

• GitHub

• HackerRank

• Personal Website

If you found this article helpful, please leave a reaction ❤️ and follow for more!


Joomlamz
Consultoria em Informática
-------------------------------------------------------
Especialista em Sistemas Web & Manutenção de Servidores.
A desenvolver o novo AplPortal com suporte a PHP 8.
Precisa de ajuda profissional? Contacte-me.

Tags: