Back to API Gateway
Q8Question 8 of 16

How to prevent replay attacks in an API Gateway

Key Concepts
  • A replay attack resends a captured legitimate request to trigger the same action again, like repeating a transfer.
  • Authentication alone does not stop replay because the captured request carries a perfectly valid token.
  • Each state-changing request must carry a unique nonce and a timestamp, both covered by a signature or HMAC.
  • The gateway rejects requests whose timestamp is outside a short freshness window (e.g., 5 minutes).
  • The gateway dedupes the nonce using Redis SET NX EX (atomic set-if-absent with TTL) to catch replays across all replicas.
  • The TTL on the nonce key matches the freshness window so Redis memory stays bounded as nonces age out.
  • Apply replay protection only to state-changing operations (transfers, payments), not to safe reads.

Definition

Make every request single-use and fresh: a signed unique nonce plus a timestamp, reject stale timestamps outside a short window, and reject any nonce already seen in Redis with a TTL equal to the window.
  • Replay attack – an attacker captures a legitimate request and resends it to trigger the same action again, such as repeating a money transfer.
  • Why auth alone fails – the captured request carries a perfectly valid JWT, so the gateway sees a valid identity and accepts the replay.
  • Nonce – a number used once; a client-generated UUID per request that the gateway checks for uniqueness.
  • Freshness window – a short time window (e.g., 5 minutes) outside which the gateway rejects the request.
  • Redis SET NX EX – atomic set-if-absent with expiry; if the key was already set, this is a replay.
  • Scope – apply only to state-changing operations (POST transfers), not to safe reads (GET balance).

Imagine Rajesh signs a cheque to pay rent and hands it over. A replay attack is someone photocopying that cheque and cashing it again. To stop this, National Bank of India stamps every cheque with a unique serial number and the exact time. The teller refuses any cheque older than a few minutes (freshness window) and keeps a short list of serial numbers already cashed (Redis dedupe). The moment a photocopy with a seen serial number shows up, it is rejected.

Understanding the Concepts

Why does authentication not stop replay attacks?

A valid JWT proves the caller's identity, but if an attacker captures a complete request including its token, they can resend the exact bytes and the gateway sees a perfectly valid request.

JAVA
// Attacker captures this legitimate request:
// POST /api/v1/transfers
// Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
// Body: {from: "SAV2001", to: "SAV3002", amount: 50000}

// Attacker resends the EXACT same request:
// Same valid JWT → gateway accepts the identity
// Same body → transfer executes AGAIN
// Rajesh is charged 50000 TWICE

// Authentication answers: "Is this a valid user?" YES
// Replay protection answers: "Has this exact request been seen before?" YES → REJECT

How does the nonce + timestamp + signature work?

The client generates a unique nonce per request and includes a timestamp. Both are covered by a signature so they cannot be tampered with.

JAVA
package com.bank.client;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.UUID;

public class ReplayProtectedRequestBuilder {

    private final String secretKey;

    public ReplayProtectedRequestBuilder(String secretKey) {
        this.secretKey = secretKey;
    }

    public ReplayProtectedRequest build(String body) {
        String nonce = UUID.randomUUID().toString();
        long timestamp = System.currentTimeMillis();
        String dataToSign = nonce + ":" + timestamp + ":" + body;
        String signature = hmacSha256(dataToSign);

        return new ReplayProtectedRequest(nonce, timestamp, body, signature);
    }

    private String hmacSha256(String data) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(
                    secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] hash = mac.doFinal(
                    data.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(hash);
        } catch (Exception e) {
            throw new RuntimeException("HMAC failed", e);
        }
    }
}
DIAGRAM
// Request headers:
// X-Nonce: 550e8400-e29b-41d4-a716-446655440000
// X-Timestamp: 1705312800000
// X-Signature: dGhpcyBpcyBhIHNpZ25hdHVyZQ==
// Signature covers nonce + timestamp + body → cannot be tampered

How does the gateway check freshness?

The gateway compares the request timestamp to the current time and rejects anything outside a short window, accounting for clock skew.

JAVA
package com.bank.gateway.filter;

import java.time.Duration;
import java.time.Instant;

public class FreshnessChecker {

    private static final Duration FRESHNESS_WINDOW = Duration.ofMinutes(5);

    public boolean isFresh(long requestTimestamp) {
        Instant requestTime = Instant.ofEpochMilli(requestTimestamp);
        Instant now = Instant.now();
        Duration age = Duration.between(requestTime, now).abs();
        return age.compareTo(FRESHNESS_WINDOW) <= 0;
    }
}
DIAGRAM
// Request timestamp: 10:00:00
// Current time: 10:03:00 → age = 3 min → FRESH (within 5 min window)
// Current time: 10:07:00 → age = 7 min → STALE → REJECT
// This bounds how long a captured request can be replayed

How does Redis dedupe the nonce?

The gateway uses Redis SET NX EX: set the key only if it does not exist, with an expiry equal to the freshness window.

JAVA
package com.bank.gateway.filter;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;

@Component
public class NonceDeduplifier {

    private static final Duration NONCE_TTL = Duration.ofMinutes(5);
    private final StringRedisTemplate redis;

    public NonceDeduplifier(StringRedisTemplate redis) {
        this.redis = redis;
    }

    /**
     * Returns true if nonce is new (not a replay).
     * Returns false if nonce was already seen (replay!).
     */
    public boolean isNewNonce(String nonce) {
        // SET nonce "1" NX EX 300
        // NX: set only if key does not exist
        // EX 300: expire after 300 seconds (5 minutes)
        Boolean wasSet = redis.opsForValue()
                .setIfAbsent("nonce:" + nonce, "1", NONCE_TTL);
        return Boolean.TRUE.equals(wasSet);
    }
}
DIAGRAM
// First request:  nonce=UUID-001 → SET nonce:UUID-001 1 NX EX 300 → key set → NEW
// Replay attempt: nonce=UUID-001 → SET NX → key exists → NOT SET → REPLAY → REJECT
// After 5 minutes: key expires → memory freed (bounded)
// Atomic NX prevents race condition where two copies both pass

How does the complete replay protection filter work?

JAVA
package com.bank.gateway.filter;

import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Component
public class ReplayProtectionFilter implements GlobalFilter {

    private final FreshnessChecker freshnessChecker;
    private final NonceDeduplifier nonceDeduplifier;
    private final SignatureVerifier signatureVerifier;

    public ReplayProtectionFilter(FreshnessChecker freshnessChecker,
                                    NonceDeduplifier nonceDeduplifier,
                                    SignatureVerifier signatureVerifier) {
        this.freshnessChecker = freshnessChecker;
        this.nonceDeduplifier = nonceDeduplifier;
        this.signatureVerifier = signatureVerifier;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // Only protect state-changing operations
        if (exchange.getRequest().getMethod() == HttpMethod.GET) {
            return chain.filter(exchange); // Safe reads skip replay check
        }

        String nonce = exchange.getRequest().getHeaders().getFirst("X-Nonce");
        String timestamp = exchange.getRequest().getHeaders().getFirst("X-Timestamp");
        String signature = exchange.getRequest().getHeaders().getFirst("X-Signature");

        // Step 1: Verify signature
        if (!signatureVerifier.verify(nonce, timestamp, signature)) {
            return reject(exchange, HttpStatus.BAD_REQUEST);
        }

        // Step 2: Check freshness
        if (!freshnessChecker.isFresh(Long.parseLong(timestamp))) {
            return reject(exchange, HttpStatus.REQUEST_TIMEOUT);
        }

        // Step 3: Check nonce uniqueness
        if (!nonceDeduplifier.isNewNonce(nonce)) {
            return reject(exchange, HttpStatus.CONFLICT); // 409: replay
        }

        return chain.filter(exchange);
    }

    private Mono<Void> reject(ServerWebExchange exchange, HttpStatus status) {
        exchange.getResponse().setStatusCode(status);
        return exchange.getResponse().setComplete();
    }
}
DIAGRAM
// Flow: signature valid → timestamp fresh → nonce new → PROCEED
// Tampered signature → 400 Bad Request
// Stale timestamp → 408 Request Timeout
// Seen nonce → 409 Conflict (replay detected!)

How is replay protection different from idempotency keys?

Replay protection defends against attackers resending captured requests. Idempotency keys make honest client retries safe. A well-designed system uses both.

DIAGRAM
// Replay protection: attacker resends captured request → REJECTED
// Idempotency key: client retries after timeout → returns stored result

// Replay protection: nonce checked at the gateway (edge)
// Idempotency key: checked at the service (business layer)

// Both prevent duplicate charges but for different reasons:
// Replay protection: security (stop attackers)
// Idempotency: reliability (safe retries for honest clients)

What happens if Redis is down?

The Redis dedupe store is a shared dependency on the critical path. For money movement, fail closed (reject) is safer than fail open (allow).

JAVA
// Fail-open policy:
// Redis down → skip nonce check → allow request
// DANGER: replays go through while Redis is down

// Fail-closed policy:
// Redis down → reject request → 503 Service Unavailable
// SAFE: no replays possible, but legitimate requests also blocked

// For banking: ALWAYS fail closed for state-changing operations
// Temporary rejection is better than duplicate transfers

What should a senior engineer know?

A senior engineer applies replay protection only to state-changing operations, makes Redis highly available, and distinguishes replay protection from idempotency. They also audit rejected replay attempts for security monitoring.

TEXT
// Senior checklist:
// 1. Scope: only POST/PUT/DELETE (state-changing), not GET
// 2. Nonce: UUID per request, covered by HMAC signature
// 3. Freshness: 5-minute window, reject stale
// 4. Redis: SET NX EX (atomic), TTL = freshness window
// 5. Failure policy: fail closed for money operations
// 6. Monitoring: log rejected replays for security alerting
// 7. Complement with idempotency keys at the service layer

Visual Overview

DIAGRAM
  Replay Protection Flow
  ──────────────────────
  Client               Gateway                    Redis
    │                     │                          │
    │── POST /transfer ──▶│                          │
    │  nonce=UUID-001      │                          │
    │  timestamp=10:00     │── verify signature       │
    │  signature=abc123    │── check freshness        │
    │                     │                          │
    │                     │── SET nonce:UUID-001 NX ─▶│
    │                     │◀── OK (key was set) ──────│
    │                     │── forward to service      │
    │◀── 200 OK ─────────│                          │

  Replay Attempt:
    │── POST /transfer ──▶│ (same nonce!)            │
    │  nonce=UUID-001      │── verify signature OK    │
    │  timestamp=10:00     │── freshness OK (within 5min)
    │                     │── SET nonce:UUID-001 NX ─▶│
    │                     │◀── NOT SET (key exists!) ─│
    │◀── 409 Conflict ───│  REPLAY DETECTED!         │

Key Takeaway

A replay attack resends a captured legitimate request to trigger the same action again, and authentication alone does not stop it because the captured request carries a perfectly valid JWT. Replay protection is a separate layer on top of authentication that makes every request provably single-use and fresh.

At National Bank of India, each state-changing request carries a unique nonce (UUID) and a timestamp, both covered by an HMAC signature so they cannot be tampered with. The gateway first verifies the signature, then checks that the timestamp is within a 5-minute freshness window, and finally checks that the nonce has not been seen before using Redis SET NX EX. The atomic NX flag is critical because it closes the race condition where two copies of a replayed request could both pass a check-then-set pattern.

The TTL on the nonce key matches the freshness window, so Redis memory stays bounded as old nonces automatically expire. For money-moving operations, the failure policy is always fail-closed: if Redis is unreachable, reject the request rather than risk letting a replay through. Replay protection complements idempotency keys: replay protection defends against attackers at the gateway edge, while idempotency keys make honest client retries safe at the service layer.

Remember one sentence: prevent replay attacks by making every state-changing request carry a signed nonce and timestamp, rejecting stale requests outside a short window, and deduping the nonce in Redis with atomic SET NX EX.

---

Share
Use ← → arrow keys to navigate