diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md
index 81fb219..d25ee36 100644
--- a/IMPLEMENTATION.md
+++ b/IMPLEMENTATION.md
@@ -187,14 +187,14 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.
- Fetch → pipeline → store → publish. Same response shape as 5.1.
- Map `ImageFetchException` to `502 Bad Gateway`.
-### [ ] 7.3 Manual check
+### [x] 7.3 Manual check
- `curl -H "Content-Type: application/json" -d '{"url":"https://..."}' .../image-url` works end-to-end.
---
## Phase 8 — Auth + concurrency + robustness
-### [ ] 8.1 `ApiKeyMiddleware`
+### [x] 8.1 `ApiKeyMiddleware`
- Matches request path `/api/*`; reads `X-Api-Key` header; constant-time compare against `ApiKeyOptions` only if `ApiKeyOptions`is set to non-empty.
- 401 on mismatch. `/i/{mac}.png` and `/health` unaffected.
diff --git a/src/FrameProcessor/Middleware/ApiKeyMiddleware.cs b/src/FrameProcessor/Middleware/ApiKeyMiddleware.cs
new file mode 100644
index 0000000..49377a7
--- /dev/null
+++ b/src/FrameProcessor/Middleware/ApiKeyMiddleware.cs
@@ -0,0 +1,48 @@
+using FrameProcessor.Configuration;
+using FrameProcessor.Domain;
+using Microsoft.Extensions.Options;
+
+namespace FrameProcessor.Middleware;
+
+///
+/// Enforces an X-Api-Key header on /api/* requests. Other paths
+/// (notably /i/{mac}.png and /health) pass through untouched.
+/// When the configured key is empty the middleware is a no-op.
+///
+public sealed class ApiKeyMiddleware
+{
+ private const string HeaderName = "X-Api-Key";
+ private readonly RequestDelegate _next;
+ private readonly IOptionsMonitor _options;
+
+ public ApiKeyMiddleware(RequestDelegate next, IOptionsMonitor options)
+ {
+ _next = next;
+ _options = options;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ if (!context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
+ {
+ await _next(context).ConfigureAwait(false);
+ return;
+ }
+
+ var configured = _options.CurrentValue.Value;
+ if (string.IsNullOrEmpty(configured))
+ {
+ await _next(context).ConfigureAwait(false);
+ return;
+ }
+
+ var provided = context.Request.Headers[HeaderName].ToString();
+ if (!new ApiKey(configured).Matches(provided))
+ {
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return;
+ }
+
+ await _next(context).ConfigureAwait(false);
+ }
+}
diff --git a/src/FrameProcessor/Program.cs b/src/FrameProcessor/Program.cs
index 7afac52..4486b17 100644
--- a/src/FrameProcessor/Program.cs
+++ b/src/FrameProcessor/Program.cs
@@ -1,5 +1,6 @@
using FrameProcessor.Configuration;
using FrameProcessor.ImagePipeline;
+using FrameProcessor.Middleware;
using FrameProcessor.Mqtt;
using FrameProcessor.Storage;
using FrameProcessor.UrlFetch;
@@ -64,6 +65,8 @@ var app = builder.Build();
// Eagerly resolve FramesRegistry so an invalid frames.json fails startup fast.
_ = app.Services.GetRequiredService();
+app.UseMiddleware();
+
app.MapControllers();
app.Run();