Securing Java Apps With Spring Security Test

Modern microservices exchange credentials every millisecond, and a single misconfigured filter can expose customer data to the wrong tenant. Hardening an API is only half the job — proving the locks actually hold under test is the other half.

Australia treats that responsibility with real teeth. The Australian Prudential Regulation Authority's CPS 234 standard obliges banks in Sydney and Melbourne to validate information security controls on a recurring schedule, and the Australian Cyber Security Centre's Essential Eight pushes the same expectation outwards to agencies and contractors in Canberra, Perth and Adelaide. Teams writing Java backends for local superannuation, healthcare and government workloads usually inherit those rules, so a passing unit-test suite rarely carries the day without dedicated security coverage.

Spring Security Test fills the gap without dragging test execution down. The module plugs straight into MockMvc, WebTestClient and JUnit, so a developer in Brisbane can verify an endpoint behaves correctly for an anonymous caller, a mocked principal and a fully authenticated user inside a single test class. The result is deterministic feedback that finishes in seconds, not the minutes a Testcontainers-based integration run would chew through.

This piece walks through the mechanics of testing authentication and authorization in Java with Spring Security Test. It covers the core annotations, MockMvc request post-processors, method-level rules, JWT and OAuth2 resource-server scenarios, and how to wire the whole thing into a CI pipeline that absorbs flaky builds without pretending a red test is green.

Why Security Testing Is Not Just Functional Testing

A normal controller test asks "does this endpoint return 200 with the right body?". A security test asks a sharper question: "is this endpoint reachable at all by a caller who shouldn't see it?". The two overlap, but the second one cannot be answered by the first. A happy-path MockMvc call that omits the SecurityContext will happily pass even when every real request would be rejected by the filter chain.

This distinction matters more in Australia because the threat model is rarely internal-only. Local banks have to assume credential stuffing against public login endpoints, and the Australian Privacy Principles put a duty on every organisation handling personal data to limit access by design. Treating security as a separate concern, with its own assertions and its own fixtures, makes failures easier to localise when a regression slips in.

The practical consequence is that a security test should never rely on a real UserDetailsService lookup unless the test is explicitly exercising that lookup. Most security tests should construct the principal in memory and then ask MockMvc to behave as if that principal had authenticated. Anything else couples the test to a database, a directory or an upstream identity provider, and the CI pipeline in an Aussie data centre starts to look very expensive very quickly.

Setting Up Spring Security Test in a Java Project

The dependency story is short. For a Spring Boot 3 application the BOM already pulls in spring-boot-starter-security, so adding spring-security-test to the test scope is enough. For older 2.7 projects, declaring the artefact directly under the same scope keeps things consistent across modules.

Configuration is even shorter. A typical setup uses @WebMvcTest for sliced tests and @SpringBootTest with MockMvc for the broader ones. The slice annotation does most of the heavy lifting — it auto-configures Spring Security's filter chain with sensible defaults, so a test that forgets to add an authenticated principal will fail loudly instead of silently passing.

A practical detail worth noting for Australian teams is the time-zone bias in CI runners. A scheduled task that expires a token at midnight UTC can expire two hours earlier in Sydney daylight saving time, which can produce a flaky suite after a 10am AEST build. Pinning the JVM clock to a fixed zone inside the test, or using a Clock bean, removes that whole class of pain.

Authentication Testing With @WithMockUser and Its Siblings

The @WithMockUser annotation is the entry point most developers reach for first. It builds an in-memory UsernamePasswordAuthenticationToken with the username, roles and authorities you specify, then binds it to the SecurityContext before the test runs. A simple @WithMockUser(roles = "ADMIN") on a test method is often all that's needed to reach a protected endpoint.

@WithUserDetails is the next step up. It loads a real user from a UserDetailsService bean, which is useful when the production code reads roles from the database rather than from a hard-coded list. The annotation accepts a value or userDetailsServiceBeanName, and the user it returns is then attached to the context exactly as @WithMockUser would.

@WithAnonymousUser flips the script. It forces the test to run without a security context, which is the right choice for verifying that a public endpoint stays public and that a protected one returns 401 rather than 500. The two siblings are not replacements — most test classes will mix them across different methods. The comparison below summarises when each annotation earns its keep.

Annotation Loads from Best for Caveat
@WithMockUser In-memory token Quick smoke tests, default-user scenarios Doesn't reflect real authorities from a database
@WithUserDetails A UserDetailsService bean Tests that need real roles or hashed passwords Requires the user to exist before the test runs
@WithAnonymousUser No context Verifying public endpoints and 401 responses Cannot be combined with other @With* on the same method
@WithSecurityContext A custom factory you write JWT claims, OAuth2 tokens, multi-tenant principals Boilerplate is heavier than the built-in annotations

Authorization Testing: Method Security and URL Patterns

Authentication proves who the caller is. Authorization decides what that caller can do, and the rules usually live in two places — URL patterns declared in a SecurityFilterChain bean and @PreAuthorize style annotations on the service layer. Spring Security Test handles both, but the assertions look slightly different.

For URL-level rules, the test pattern is to invoke MockMvc with the right post-processor and assert the response status. status().isForbidden() for a wrong role, status().isUnauthorized() for a missing token, status().isOk() for the happy case. That triple-covers the contract for most controllers an Australian team will write for a customer-facing API.

For method-level rules, the same annotations apply but the call goes through a proxy. @PreAuthorize("hasRole('AUDITOR')") on a service method will reject a @WithMockUser(roles = "USER") principal, and the test should assert that the call throws AccessDeniedException rather than returning a value. Some teams wrap the call in assertThrows; others prefer an @SpringBootTest with AopAutoProxyCreator so the proxy is real. Both are valid, and the deciding question is whether the surrounding context is needed. A point worth flagging for teams working on government contracts is that method-level checks often need to consider data residency, and the test should exercise each rule independently rather than bundling them into a single mega-test that obscures which rule actually fired.

Mocking Principals With SecurityMockMvcRequestPostProcessors

Annotations are tidy but not always expressive enough. SecurityMockMvcRequestPostProcessors is the fluent alternative that lives in the static user(), anonymous() and jwt() helpers, and it lets a test method build the principal inline with the request. A single call replaces a whole annotation.

The user() post-processor accepts a UserDetails object, a username, or a fully built Authentication, which makes it the right tool when the test needs a custom principal such as a multi-tenant user from a TenantAwareAuthenticationToken. It is also the cleanest way to test CSRF behaviour, because csrf() is itself a post-processor in the same family.

When the project uses JWT or an opaque token, the jwt() post-processor builds a JwtAuthenticationToken with the claims you specify. For OAuth2 resource-server endpoints this is the closest thing to a real request without actually decoding a real token, and it lets a test assert that a missing scope claim triggers 403 rather than 401. The Australian banking sector in particular uses opaque tokens through intermediaries, and the post-processor pattern is the only realistic way to simulate that without a live issuer in the loop.

Testing JWT, OAuth2 and Resource Server Endpoints

Resource-server testing has its own moving parts. The endpoint expects a Bearer token, the filter chain parses it into a Jwt and the controller rarely sees anything beyond the principal. Testing that flow means three things: a way to provide a token, a way to assert the parsed claims, and a way to verify the right scopes are enforced.

The jwt() post-processor handles the first two out of the box. Supplying jwt().jwt(jwt -> jwt.subject("alice").claim("scope", "read:users")) builds the right JwtAuthenticationToken, and the test then asserts the controller's behaviour. For negative tests, jwt().jwt(jwt -> jwt.claim("scope", "")) is a neat way to assert that an empty scope rejects the call.

OAuth2 login flows are harder to test in isolation because they involve a redirect dance. Most teams settle on mocking the OAuth2AuthorizedClientService and asserting that the controller receives an OAuth2AuthenticationToken with the right registration id and principal name. A growing number of Australian fintechs in Sydney's CBD rely on Atlassian-style access tokens, and the resource-server test suite is usually where misconfigured issuer URIs surface first. Catching those in CI rather than at 2am AEST is the difference between a quiet week and a PagerDuty incident. For a working end-to-end walkthrough of the configuration shown above, Oscar David Sanchez walks through a real project setup in a similar vein.

Integrating Security Tests Into a Reliable CI Pipeline

A security test suite is only useful if it runs on every push. Wiring it into a GitHub Actions or GitLab pipeline is straightforward, but the discipline that keeps the suite honest is harder. Running tests on every pull request is the baseline; running the security subset on every merge to main is the higher bar that catches regressions before they hit production.

Flaky tests are the silent killer here. A @WithMockUser test that occasionally fails because a static clock ticked past midnight should be retried once and then quarantined, not silently ignored. Most CI providers offer a retry-on-failure option, and pairing it with a quarantine label keeps the main branch honest without blocking every developer in Melbourne's Java User Group from getting their PR reviewed.

The last mile is reporting. Surfacing security-test failures as a separate job in the pipeline UI, with its own badge and its own notification channel, makes the failures visible without burying them inside the general unit-test summary. For Australian teams subject to APRA reporting, that visibility doubles as an audit trail — every failed security test becomes a record of a control that did not hold on a given date, which is exactly the evidence a regulator asks for during a CPS 234 review.

Pick a single protected endpoint in your codebase this week and write one test annotated @WithMockUser and one annotated @WithAnonymousUser against it. Wire both into a dedicated CI job, run them through to green, and the security-test loop is no longer an aspiration but a working part of the pipeline — once those two tests are stable, expanding coverage to a third role and a custom JWT claim takes an afternoon rather than a sprint.