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
- Accept
Idempotency-Keyfor POST endpoints. - Process the request only once per key (within a TTL).
- Replay the same status code + response body for duplicates.
- Keep the implementation simple (in-memory store for single instance).
How It Works
- Client sends
POST /api/reservationswith headerIdempotency-Key: ... - Server checks if the key is already stored
- If stored: return the cached response (and optionally add
Idempotency-Replay: true) - If not stored: execute the endpoint, capture the response, store it, return it
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
- Multi-instance: IMemoryCache works per instance. If you run multiple servers, use a shared store (Redis/DB).
- Scope keys: Consider including user id / tenant id in the cache key if needed (to prevent cross-user collisions).
- Body mismatch: Some systems also store a hash of the request body and reject the same key with different payload.
- What to cache: Many APIs cache 2xx (and sometimes 4xx). Decide based on your business semantics.
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.