Skip to main content
the auth layer

Token Replay and CSRF: Attacks That Exploit Valid Tokens

7 min read Chapter 32 of 45

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.

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:

  1. User Alice is logged into app.saas.example with an active session.
  2. Alice visits attacker.example (via phishing, ad network, compromised forum post).
  3. attacker.example contains: <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>
  4. Alice’s browser submits the form to app.saas.example with her session cookie.
  5. 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

MitigationMechanismWhat it prevents
Short TTL (5 min)Token expires quicklyLimits replay window
Token binding (DPoP)Proof of key ownershipReplayed token rejected without private key
jti + revocation listUnique token ID, single-use enforcementSecond use of same jti is rejected
Sender-constrained tokens (mTLS)Token bound to TLS client certificateToken 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.