
A practical, in‑depth tutorial on wiring API key authentication into Spring Security for stateless REST services. It covers custom AuthenticationToken, provider, filter chain, secure key storage, testing, and production hardening.
Why API Keys Over Traditional Session‑Based Security
Spring Security’s out‑of‑the‑box configuration is built around a stateful, form‑login flow. It creates an HttpSession, stores a SecurityContext in that session, and expects the client to return a session cookie on each request. This model works for traditional web UI applications, but it collides with the design goals of a REST API, which is supposed to be stateless: every request must contain all information required for authentication and authorization without relying on server‑side session data.
When a REST endpoint is protected with the default setup, developers encounter several friction points:
- CSRF protection is automatically enabled, forcing clients to obtain and send a CSRF token that is irrelevant for API calls.
- Redirects to a login page are generated for unauthenticated requests, breaking the JSON contract expected by API consumers.
- Session cookies appear in the HTTP contract, requiring clients to manage cookie storage even though they never requested it.
These mismatches make the default flow unsuitable for micro‑services, mobile apps, or third‑party integrations that expect a pure HTTP/JSON interaction. A pragmatic alternative is to replace the heavyweight OAuth or JWT scaffolding with a simple API key. An API key is a secret string sent in a request header (e.g., X‑API‑KEY) and validated on each call, preserving statelessness while leveraging Spring’s extensible filter chain.
Typical implementation steps are:
- Create a lightweight
AuthenticationTokenthat holds the raw key (seeApiKeyAuthenticationTokenin the evidence). - Implement an
AuthenticationProviderthat looks up the hashed key in a repository and, on a match, returns an authenticated token with the user’s authorities. - Register a
OncePerRequestFilterthat extracts the header, builds an unauthenticated token, and delegates to the provider.
Key operational considerations include:
- Never store raw keys; hash them (e.g., BCrypt) before persisting.
- Fail fast with
401 Unauthorizedwithout indicating whether the key was missing or invalid to avoid enumeration attacks. - Isolate filter, provider, and repository for unit testing, and add rate‑limiting or revocation logic via Spring Actuator for production hardening.
By aligning authentication with the stateless nature of REST, an API key approach eliminates session leakage, simplifies client code, and fits cleanly into Spring Security’s architecture without the overhead of full OAuth flows.
Creating a Lightweight AuthenticationToken
In Spring Security the Authentication interface represents the security context of a request. A custom token is required when the default username‑password or JWT mechanisms do not match the API contract. The ApiKeyAuthenticationToken is a lightweight implementation that carries only the raw API key and, once validated, the associated UserDetails and granted authorities.
The class defines two distinct constructors that encode the token’s lifecycle:
- Unauthenticated constructor – invoked by the filter after extracting the key from a request header. It stores the raw key, leaves the principal
null, and callssetAuthenticated(false). This signals to theAuthenticationProviderthat the token still requires verification. - Authenticated constructor – used by the provider after a successful lookup. It receives a populated
UserDetailsinstance, the same key (or a masked version), and a collection ofGrantedAuthority. The call tosetAuthenticated(true)marks the token as trusted, allowing downstream security checks to rely on its authorities.
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
private final String apiKey;
private final UserDetails principal;
// Unauthenticated instance
public ApiKeyAuthenticationToken(String apiKey) {
super(null);
this.apiKey = apiKey;
this.principal = null;
setAuthenticated(false);
}
// Authenticated instance
public ApiKeyAuthenticationToken(UserDetails principal,
String apiKey,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.apiKey = apiKey;
this.principal = principal;
setAuthenticated(true);
}
@Override
public Object getCredentials() { return apiKey; }
@Override
public Object getPrincipal() { return principal; }
}
Because the filter creates the unauthenticated token and passes it to an AuthenticationProvider, the token becomes the cornerstone of the authentication flow: the provider validates the key (typically by comparing a hashed value stored in a database), constructs the authenticated token, and returns it to the security context. Subsequent authorization decisions consult the token’s authorities, keeping controller code free of security concerns.
Practical guidelines for using this token include:
- Never persist raw keys; hash them with a strong algorithm such as BCrypt before storage.
- Fail fast with a
401 Unauthorizedresponse without indicating whether the key was missing or invalid, mitigating enumeration attacks. - Isolate the filter, provider, and repository in unit tests to verify edge cases (e.g., malformed headers, revoked keys) without starting a full server.
Configuring the Security Filter Chain and Provider
To implement API key authentication in Spring Security, one must transition from session-based flows to a stateless architecture. A robust approach involves creating a custom AuthenticationToken, a OncePerRequestFilter for interception, and a dedicated AuthenticationProvider for validation. This separation of concerns ensures that authentication logic remains decoupled from business controllers.
The flow functions as follows:
- Token Definition: Create a class extending
AbstractAuthenticationTokento hold the raw API key. Initialize it as unauthenticated until the provider verifies the credentials. - Filter Interception: Implement
OncePerRequestFilterto extract the API key from the request header. Upon extraction, instantiate the unauthenticated token and pass it to theAuthenticationManager. - Provider Validation: Define an
AuthenticationProviderthat implements theauthenticatemethod. This provider performs the lookup against your data store, hashes the incoming key (e.g., using BCrypt), and returns a fully populatedAuthenticationTokencontaining user authorities if the match is successful.
Consider the following implementation pattern for the authentication token:
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
private final String apiKey;
private final UserDetails principal;
public ApiKeyAuthenticationToken(String apiKey) {
super(null);
this.apiKey = apiKey;
this.principal = null;
setAuthenticated(false);
}
// Constructor for authenticated state...
}
When configuring these components, prioritize security hardening by following these practices:
- Fail Silently: Always return a
401 Unauthorizedstatus for invalid or missing keys to prevent user enumeration attacks. - Secure Storage: Never store raw API keys. Persist only the hashes in your repository and compare the hashed input during the provider's validation phase.
- Isolation: Test the filter, provider, and repository layers in isolation to verify edge cases without requiring a full server context.
Securely Storing and Managing API Keys
When an API key is used for service‑to‑service authentication, the key must never be persisted in clear text. Spring Data JPA can store a hashed representation of the key, while the application logic handles verification, rotation, and multi‑key support per client.
Entity design
@EntityclassApiKeycontainsid,clientId,hashedKey,createdAt,expiresAt, andactiveflags.- The
hashedKeycolumn stores the result ofBCryptPasswordEncoder.encode(rawKey), which includes a salt and a work factor. - Multiple rows can reference the same
clientId, enabling a client to rotate keys without downtime.
Persisting a new key
@Service
public class ApiKeyService {
private final ApiKeyRepository repo;
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
public ApiKey createKey(String clientId) {
String raw = UUID.randomUUID().toString().replace("-", "");
ApiKey entity = new ApiKey();
entity.setClientId(clientId);
entity.setHashedKey(encoder.encode(raw));
entity.setCreatedAt(Instant.now());
entity.setActive(true);
repo.save(entity);
// Return the raw key only once, e.g., via a secure channel
return raw;
}
}
Authentication flow
- A custom
OncePerRequestFilterextracts the key from a header (e.g.,X‑API‑KEY). - The filter builds an unauthenticated
ApiKeyAuthenticationTokenand delegates to anAuthenticationProvider. - The provider loads all active
ApiKeyrows for the suppliedclientIdand usesBCryptPasswordEncoder.matches(raw, hashed)to verify. - On success, an authenticated token with the client’s authorities is returned; on failure, a 401 response is sent without indicating whether the key was missing or invalid (preventing enumeration attacks).
Rotation strategy
- Generate a new key and store it as a separate row while keeping the old key active.
- Update client configuration to use the new key; the old key remains valid for a grace period.
- After the grace period, mark the old row
active = falseand optionally delete it.
Implementing these practices satisfies requirements of security frameworks such as SOC 2, ISO 27001, NIST SP 800‑63B, and OWASP ASVS, which all mandate protection of secrets at rest and controlled key lifecycle management. By hashing keys with BCrypt, avoiding raw storage, and supporting seamless rotation, the system reduces the risk of credential leakage and supports robust auditability.
Testing the Authentication Components
When an API‑key authentication flow is built with Spring Security, the filter, AuthenticationProvider, and controller should each be testable in isolation. Isolating these components avoids the overhead of starting an embedded servlet container and makes the test suite fast enough for continuous integration pipelines.
Unit‑testing the filter
The custom OncePerRequestFilter extracts the header, creates an ApiKeyAuthenticationToken, and forwards it to the authentication manager. A unit test can verify this behavior by supplying a mock HttpServletRequest and a stubbed AuthenticationManager.
@Test
void filterShouldCreateUnauthenticatedToken() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("X‑API‑KEY", "test-key");
MockHttpServletResponse response = new MockHttpServletResponse();
AuthenticationManager manager = mock(AuthenticationManager.class);
when(manager.authenticate(any())).thenReturn(
new ApiKeyAuthenticationToken(userDetails, "test-key", List.of()));
ApiKeyFilter filter = new ApiKeyFilter(manager);
filter.doFilterInternal(request, response, mock(FilterChain.class));
verify(manager).authenticate(argThat(
token -> token instanceof ApiKeyAuthenticationToken &&
"test-key".equals(token.getCredentials())));
}
- Missing header → expect
401without revealing the cause. - Malformed key (e.g., empty string) → same fast‑fail response.
- Exception from the manager → ensure the filter propagates a proper
AuthenticationException.
Unit‑testing the provider
The provider validates the raw key against a persisted hash (e.g., BCrypt). Mock the repository that stores hashed keys and assert both success and failure paths.
@Test
void providerAuthenticatesValidKey() {
ApiKeyRepository repo = mock(ApiKeyRepository.class);
when(repo.findHashByKey("valid-key"))
.thenReturn(Optional.of(bCryptEncoder.encode("valid-key")));
ApiKeyAuthenticationProvider provider = new ApiKeyAuthenticationProvider(repo);
Authentication result = provider.authenticate(
new ApiKeyAuthenticationToken("valid-key"));
assertTrue(result.isAuthenticated());
assertEquals("valid-key", result.getCredentials());
}
- Invalid key → provider returns
nullor throwsBadCredentialsException. - Key not found → same outcome, preventing enumeration.
- Repository throws → verify that the exception is wrapped as an
AuthenticationServiceException.
Integration‑style test for the controller
Using @WebMvcTest loads only the MVC layer and the filter chain, not the full server. Inject a mock provider to control authentication outcomes.
@WebMvcTest(secured = true)
class ApiControllerTest {
@Autowired MockMvc mvc;
@MockBean ApiKeyAuthenticationProvider provider;
@Test
void requestWithValidKeyIsAuthorized() throws Exception {
when(provider.authenticate(any()))
.thenReturn(new ApiKeyAuthenticationToken(user, "key", List.of()));
mvc.perform(get("/api/resource")
.header("X‑API‑KEY", "key"))
.andExpect(status().isOk());
}
@Test
void requestWithMissingKeyIsRejected() throws Exception {
mvc.perform(get("/api/resource"))
.andExpect(status().isUnauthorized());
}
}
These tests cover edge cases such as missing headers, malformed keys, and repository failures while keeping the runtime footprint minimal.
Production Hardening: Rate Limiting, Revocation, and Monitoring
API keys are a lightweight alternative to OAuth or JWT for protecting stateless REST endpoints, but they introduce operational risks that must be mitigated before a service is considered production‑ready. The first step is to understand the common failure modes and then apply concrete controls such as rate limiting, revocation, and observability.
Typical pitfalls
- Logging raw keys – Frameworks like Spring Boot often log request headers at DEBUG level. If the
Authorizationor custom API‑key header is written verbatim, the secret is exposed in log aggregation systems. - Unlimited request volume – Without throttling a compromised key can be used to launch denial‑of‑service attacks against downstream services.
- Stale or leaked keys – Keys that are not revoked after employee turnover or credential rotation remain valid, violating controls required by SOC 2, ISO 27001, and NIST SP 800‑57.
- Insufficient visibility – Without metrics, anomalous usage patterns (e.g., a sudden spike from a single client) go unnoticed, breaching OWASP API Security Top 10 recommendations for monitoring.
Production‑hardening techniques
- Rate limiting – Implement a
OncePerRequestFilterthat consults a token bucket stored in Redis or an in‑memory cache. Example:if (!bucket.tryConsume(1)) { response.sendError(HttpServletResponse.SC_TOO_MANY_REQUESTS); return; } - Revocation – Store a
revokedflag alongside the hashed key in the database. TheAuthenticationProviderchecks this flag and returns401for revoked entries. Rotate keys by generating a new secret, hashing it (e.g., BCrypt), and updating the record while preserving the old hash for a grace period. - Monitoring with Spring Actuator – Expose custom metrics such as
apiKey.requestsandapiKey.errors. Register aMeterBinderthat increments counters per client identifier, then query via/actuator/metrics/apiKey.requests. Combine with/actuator/loggersto ensure key‑header logging is disabled at runtime.
By eliminating raw‑key logs, enforcing per‑client throttling, providing a revocation workflow, and instrumenting usage through Actuator, an API‑key implementation aligns with the access‑control and audit‑logging requirements of SOC 2, ISO 27001, and NIST, while satisfying OWASP’s guidance for secure API design.
Looking for Custom Software or AI Solutions?
Appworks Technologies designs, builds, and scales production enterprise platforms, microservices, and AI agent workflows tailored to your business goals.
Editorial Policy & Research Methodology
Our findings are based on rigorous internal research, verified industry benchmarks, and direct technical implementation experience from our enterprise client projects. All statistics and technical claims are reviewed by senior engineers before publication to ensure accuracy, transparency, and helpfulness for our readers.
