Token Replay and CSRF: Attacks That Exploit Valid Tokens
Token Replay and CSRF
Token Replay, CSRF, and When They Overlap
The Assumption
Bearer tokens are secure because they are signed. CSRF is irrelevant for APIs that use token-based auth. The assumption: a valid signature means the request is legitimate.
A valid signature means the token was issued by the authorization server. It says nothing about whether the presenter is the intended recipient. A replayed token has a valid signature. A token stolen via XSS and sent from an attacker’s machine has a valid signature.
CSRF is considered irrelevant for bearer token APIs because CSRF exploits the browser’s automatic cookie attachment. If authentication uses a header-based bearer token, the browser does not automatically attach it, so CSRF is impossible. But the SaaS platform uses cookies for browser sessions (CH7). Cookies are automatically attached. CSRF is relevant.
The Attack
Token Replay
Scenario 1: Token stolen from logs. A developer adds request logging middleware that logs the full Authorization header. The logs are shipped to a centralized logging service (ELK, Splunk, Datadog). An attacker with access to the logging dashboard (compromised account, insider threat, exposed Kibana instance) extracts bearer tokens from the logs and replays them.
Scenario 2: Token stolen via XSS. The SaaS platform has a stored XSS vulnerability in a user comment field. An attacker injects JavaScript that reads localStorage (where the mobile app stores the access token) and sends it to https://attacker.example/collect. The attacker replays the token from their own machine.
Scenario 3: Token intercepted on the network. A mobile user connects to an open WiFi network. Despite HTTPS, the user has accepted a malicious CA certificate (common in corporate MITM proxies or malware-installed CAs). The proxy intercepts the TLS connection and extracts the bearer token. The proxy operator replays the token.
CSRF Against Cookie-Based Auth
The SaaS platform’s browser frontend uses HTTP-only session cookies (CH7). The session cookie is automatically attached to every request to app.saas.example. CSRF attack:
- User Alice is logged into
app.saas.examplewith an active session. - Alice visits
attacker.example(via phishing, ad network, compromised forum post). attacker.examplecontains:<form action="https://app.saas.example/api/billing/change-plan" method="POST"><input name="plan" value="enterprise"><input type="submit"></form><script>document.forms[0].submit();</script>- Alice’s browser submits the form to
app.saas.examplewith her session cookie. - The server sees a valid session, a valid request, and upgrades Alice’s billing plan.
The SameSite=Lax cookie attribute prevents this for POST requests from cross-origin sites (the cookie is not sent). But SameSite=Lax allows GET requests with cookies from cross-origin navigations. If any state-changing operation uses GET (a misconfigured endpoint), SameSite=Lax does not protect it.
The Spec or Mechanism
Token Replay Mitigations
| Mitigation | Mechanism | What it prevents |
|---|---|---|
| Short TTL (5 min) | Token expires quickly | Limits replay window |
| Token binding (DPoP) | Proof of key ownership | Replayed token rejected without private key |
| jti + revocation list | Unique token ID, single-use enforcement | Second use of same jti is rejected |
| Sender-constrained tokens (mTLS) | Token bound to TLS client certificate | Token only valid from original TLS session |
DPoP (Demonstrating Proof-of-Possession, RFC 9449) binds the token to a key pair held by the client. Each request includes a DPoP proof (a signed JWT proving possession of the private key). A replayed token without the matching DPoP proof is rejected.
CSRF Protection in Spring Security
Spring Security provides two CSRF token repositories:
HttpSessionCsrfTokenRepository: Stores the CSRF token in the session. Traditional approach for server-rendered HTML forms.CookieCsrfTokenRepository: Stores the CSRF token in a cookie. The JavaScript application reads the cookie and sends the token in a header. Used for SPA frontends.
The Implementation
Token Replay: DPoP in Spring Security
// VULNERABLE: Plain bearer token, replayable
@Bean
public SecurityFilterChain vulnerableChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
// Any bearer of this token can use it
)
.build();
}
// HARDENED: DPoP-bound tokens
@Bean
public SecurityFilterChain hardenedChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(dpopAwareJwtDecoder())
)
)
.addFilterBefore(dpopProofFilter(), BearerTokenAuthenticationFilter.class)
.build();
}
@Bean
public JwtDecoder dpopAwareJwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("https://auth.saas.example/oauth2/jwks")
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
// Validate that the token's cnf (confirmation) claim matches the DPoP proof
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("https://auth.saas.example"),
dpopConfirmationValidator()
));
return decoder;
}
@Component
public class DpopProofFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) throws Exception {
String dpopHeader = request.getHeader("DPoP");
if (dpopHeader == null) {
// No DPoP proof provided
chain.doFilter(request, response);
return;
}
// Parse and validate the DPoP proof JWT
SignedJWT dpopProof = SignedJWT.parse(dpopHeader);
JWSHeader header = dpopProof.getHeader();
// Validate required DPoP proof claims
JWTClaimsSet claims = dpopProof.getJWTClaimsSet();
// htm: HTTP method
if (!request.getMethod().equals(claims.getStringClaim("htm"))) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "DPoP htm mismatch");
return;
}
// htu: HTTP URI (without query and fragment)
String expectedUri = request.getRequestURL().toString();
if (!expectedUri.equals(claims.getStringClaim("htu"))) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "DPoP htu mismatch");
return;
}
// jti: unique identifier (prevent DPoP proof replay)
String jti = claims.getJWTID();
if (isDpopJtiReused(jti)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "DPoP proof replayed");
return;
}
recordDpopJti(jti, Duration.ofMinutes(5));
// Verify the DPoP proof signature using the embedded public key
JWK jwk = header.getJWK();
if (!dpopProof.verify(new RSASSAVerifier((RSAKey) jwk))) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid DPoP proof");
return;
}
// Store the DPoP public key thumbprint for token binding verification
String thumbprint = jwk.computeThumbprint().toString();
request.setAttribute("dpop_thumbprint", thumbprint);
chain.doFilter(request, response);
}
}
CSRF Protection for Mixed Auth
@Bean
public SecurityFilterChain browserApiChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
// Exempt webhook endpoints (authenticated via signature, not session)
.ignoringRequestMatchers("/api/webhooks/**")
)
.oauth2ResourceServer(oauth2 -> oauth2
.opaqueToken(opaque -> opaque
.introspector(cachingIntrospector())
)
)
.build();
}
// Custom handler for SPA: reads CSRF token from X-XSRF-TOKEN header
static class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
Supplier<CsrfToken> csrfTokenSupplier) {
// Load the token to ensure cookie is set
CsrfToken csrfToken = csrfTokenSupplier.get();
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
// Read from header (SPA) or parameter (form)
String headerToken = request.getHeader(csrfToken.getHeaderName());
return headerToken != null ? headerToken :
super.resolveCsrfTokenValue(request, csrfToken);
}
}
Log Sanitization (Preventing Token Leakage)
@Component
public class AuthHeaderSanitizingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) throws Exception {
// Wrap the request to sanitize the Authorization header in logging
HttpServletRequest wrapped = new HttpServletRequestWrapper(request) {
@Override
public String getHeader(String name) {
if ("authorization".equalsIgnoreCase(name)) {
String value = super.getHeader(name);
if (value != null && value.toLowerCase().startsWith("bearer ")) {
// Only expose first 8 chars for correlation
return "Bearer " + value.substring(7,
Math.min(15, value.length())) + "...[REDACTED]";
}
}
return super.getHeader(name);
}
};
chain.doFilter(wrapped, response);
}
}
The Verification
@SpringBootTest
@AutoConfigureMockMvc
class TokenReplayAndCsrfTest {
@Autowired
private MockMvc mockMvc;
@Test
void csrfTokenRequiredForStateChangingOperations() throws Exception {
// Login and get session
MvcResult loginResult = mockMvc.perform(post("/login")
.param("username", "alice")
.param("password", "correct-password"))
.andReturn();
MockHttpSession session = (MockHttpSession) loginResult.getRequest().getSession();
// POST without CSRF token should fail
mockMvc.perform(post("/api/billing/change-plan")
.session(session)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"plan\": \"enterprise\"}"))
.andExpect(status().isForbidden());
// POST with CSRF token should succeed
CsrfToken csrfToken = (CsrfToken) loginResult.getRequest()
.getAttribute(CsrfToken.class.getName());
mockMvc.perform(post("/api/billing/change-plan")
.session(session)
.header("X-XSRF-TOKEN", csrfToken.getToken())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"plan\": \"enterprise\"}"))
.andExpect(status().isOk());
}
@Test
void getRequestsDoNotRequireCsrf() throws Exception {
MockHttpSession session = authenticatedSession();
// GET requests with SameSite=Lax cookies work without CSRF
mockMvc.perform(get("/api/projects").session(session))
.andExpect(status().isOk());
}
@Test
void webhookEndpointExemptFromCsrf() throws Exception {
// Webhooks are authenticated via signature, not session
mockMvc.perform(post("/api/webhooks/payment")
.header("X-Webhook-Signature", "valid-signature")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"event\": \"payment.completed\"}"))
.andExpect(status().isOk()); // No CSRF required
}
}
The first test proves CSRF enforcement: a state-changing POST without the CSRF token is rejected, even with a valid session. This is the defense against the cross-origin form submission attack. The third test proves that webhook endpoints (authenticated via request signature, not session cookies) are correctly exempted from CSRF requirements.