Loading...

Idempotency Key in ASP.NET Core (.NET 8): Prevent Duplicate POST Requests

Use an Idempotency-Key header to make POST requests safe to retry: cache the first response and replay it for duplicates, avoiding double inserts.

POST requests are not idempotent by default. If a client retries the same POST due to a network timeout, the server might process it twice and create duplicate data (double insert, double charge, double reservation, etc.). This is common in mobile apps, flaky networks, and any client that uses automatic retries.

A practical pattern is an Idempotency-Key: the client sends a unique key in a header. The server processes the request once, stores the response, and if the same key is sent again, it replays the original response instead of executing the operation again.


Goal


How It Works


Step 1: A Small Store Contract

In production, idempotency should usually be backed by a shared store (Redis/DB) if you run multiple instances. For this example we use an in-memory store so the idea is easy to follow.


public sealed record IdempotencyRecord(
    int StatusCode,
    string ContentType,
    byte[] Body,
    Dictionary<string, string> Headers,
    DateTimeOffset CreatedAt);

public interface IIdempotencyStore
{
    Task<IdempotencyRecord?> GetAsync(string key, CancellationToken ct);
    Task SetAsync(string key, IdempotencyRecord record, TimeSpan ttl, CancellationToken ct);
}

Step 2: In-Memory Store (IMemoryCache)


using Microsoft.Extensions.Caching.Memory;

public sealed class MemoryIdempotencyStore(IMemoryCache cache) : IIdempotencyStore
{
    private readonly IMemoryCache _cache = cache;

    public Task<IdempotencyRecord?> GetAsync(string key, CancellationToken ct)
    {
        _cache.TryGetValue(key, out IdempotencyRecord? record);
        return Task.FromResult(record);
    }

    public Task SetAsync(string key, IdempotencyRecord record, TimeSpan ttl, CancellationToken ct)
    {
        _cache.Set(key, record, ttl);
        return Task.CompletedTask;
    }
}

Step 3: Middleware (Capture + Replay)

The middleware checks Idempotency-Key on POST requests. If it finds a cached record, it writes the same response back. Otherwise, it captures the outgoing response body and stores it after the endpoint completes.


using System.Text;
using Microsoft.AspNetCore.Http;

public sealed class IdempotencyMiddleware(
    RequestDelegate next,
    IIdempotencyStore store)
{
    private readonly RequestDelegate _next = next;
    private readonly IIdempotencyStore _store = store;

    // Keep it small for the demo
    private static readonly TimeSpan DefaultTtl = TimeSpan.FromMinutes(10);

    public async Task InvokeAsync(HttpContext context)
    {
        if (!HttpMethods.IsPost(context.Request.Method))
        {
            await _next(context);
            return;
        }

        if (!context.Request.Headers.TryGetValue("Idempotency-Key", out var keyValues))
        {
            await _next(context);
            return;
        }

        var key = keyValues.ToString().Trim();

        if (string.IsNullOrWhiteSpace(key) || key.Length > 128)
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsync("Invalid Idempotency-Key.");
            return;
        }

        // Include path in the cache key to avoid collisions across endpoints
        var cacheKey = $"{context.Request.Path}:{key}";

        // 1) Replay if exists
        var existing = await _store.GetAsync(cacheKey, context.RequestAborted);
        if (existing is not null)
        {
            context.Response.StatusCode = existing.StatusCode;
            context.Response.ContentType = existing.ContentType;

            foreach (var h in existing.Headers)
                context.Response.Headers[h.Key] = h.Value;

            context.Response.Headers["Idempotency-Replay"] = "true";

            await context.Response.Body.WriteAsync(existing.Body, context.RequestAborted);
            return;
        }

        // 2) Capture response
        var originalBody = context.Response.Body;

        await using var buffer = new MemoryStream();
        context.Response.Body = buffer;

        try
        {
            await _next(context);

            // Store only successful responses for demo.
            // In real systems you may store 4xx too (depends on your semantics).
            var status = context.Response.StatusCode;
            var contentType = context.Response.ContentType ?? "application/json";

            buffer.Position = 0;
            var bodyBytes = buffer.ToArray();

            // Copy response to the real stream
            buffer.Position = 0;
            await buffer.CopyToAsync(originalBody, context.RequestAborted);

            // Store
            var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

            // Store a minimal set of headers (avoid Set-Cookie etc.)
            if (context.Response.Headers.TryGetValue("Location", out var loc))
                headers["Location"] = loc.ToString();

            var record = new IdempotencyRecord(
                StatusCode: status,
                ContentType: contentType,
                Body: bodyBytes,
                Headers: headers,
                CreatedAt: DateTimeOffset.UtcNow);

            await _store.SetAsync(cacheKey, record, DefaultTtl, context.RequestAborted);
        }
        finally
        {
            context.Response.Body = originalBody;
        }
    }
}

Step 4: Wire It Up (Program.cs)


using Microsoft.Extensions.Caching.Memory;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

// In-memory store for demo (single instance)
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IIdempotencyStore, MemoryIdempotencyStore>();

var app = builder.Build();

app.UseMiddleware<IdempotencyMiddleware>();

app.MapControllers();
app.Run();

Example Endpoint

The endpoint stays clean—no idempotency logic inside the controller.


using Microsoft.AspNetCore.Mvc;

public sealed record CreateReservationRequest(string Code, string Country);

[ApiController]
[Route("api/reservations")]
public sealed class ReservationsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create([FromBody] CreateReservationRequest req)
    {
        // Imagine this inserts into DB and returns a new resource id.
        var id = Random.Shared.Next(1000, 9999);

        return Created($"/api/reservations/{id}", new
        {
            id,
            req.Code,
            req.Country
        });
    }
}

Test Quickly

Send the same POST twice with the same key. The second response should be a replay.


curl -i -X POST "https://localhost:5001/api/reservations" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7d2f4f2b-7bcb-4f8f-a5e2-0d9b6f4e8f1a" \
  -d "{\"code\":\"RSV-001\",\"country\":\"TR\"}"

curl -i -X POST "https://localhost:5001/api/reservations" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7d2f4f2b-7bcb-4f8f-a5e2-0d9b6f4e8f1a" \
  -d "{\"code\":\"RSV-001\",\"country\":\"TR\"}"

You should see Idempotency-Replay: true on the second response and the same body/status as the first call.


Important Notes


TL;DR

  • Idempotency-Key makes POST safe to retry.
  • Store the first response and replay it for duplicate keys.
  • Use a shared store (Redis/DB) if you run multiple instances.