diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md
index ad4eb46..3396476 100644
--- a/IMPLEMENTATION.md
+++ b/IMPLEMENTATION.md
@@ -63,7 +63,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.
- `enum { FloydSteinberg, Atkinson, Stucki, Jarvis }`.
- JSON converter reading kebab-case (`floyd-steinberg`, etc.).
-### [ ] 1.7 `ApiKey`
+### [x] 1.7 `ApiKey`
- `record struct` wrapping a string.
- `bool Matches(string candidate)` using `CryptographicOperations.FixedTimeEquals` over UTF-8 bytes.
diff --git a/src/FrameProcessor/Domain/ApiKey.cs b/src/FrameProcessor/Domain/ApiKey.cs
new file mode 100644
index 0000000..4565f9b
--- /dev/null
+++ b/src/FrameProcessor/Domain/ApiKey.cs
@@ -0,0 +1,33 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace FrameProcessor.Domain;
+
+///
+/// Wraps the shared API key. uses a constant-time comparison
+/// over UTF-8 bytes to avoid leaking key length or content via timing side channels.
+///
+public readonly record struct ApiKey
+{
+ private readonly string? _value;
+
+ public ApiKey(string value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ _value = value;
+ }
+
+ public string Value => _value ?? string.Empty;
+
+ public bool Matches(string? candidate)
+ {
+ if (candidate is null)
+ {
+ return false;
+ }
+
+ var expectedBytes = Encoding.UTF8.GetBytes(Value);
+ var candidateBytes = Encoding.UTF8.GetBytes(candidate);
+ return CryptographicOperations.FixedTimeEquals(expectedBytes, candidateBytes);
+ }
+}
diff --git a/tests/FrameProcessor.Tests/ApiKeyTests.cs b/tests/FrameProcessor.Tests/ApiKeyTests.cs
new file mode 100644
index 0000000..027aa51
--- /dev/null
+++ b/tests/FrameProcessor.Tests/ApiKeyTests.cs
@@ -0,0 +1,78 @@
+using FrameProcessor.Domain;
+
+namespace FrameProcessor.Tests;
+
+public class ApiKeyTests
+{
+ [Fact]
+ public void Matches_ReturnsTrue_ForIdenticalKey()
+ {
+ var key = new ApiKey("s3cret-value");
+ Assert.True(key.Matches("s3cret-value"));
+ }
+
+ [Fact]
+ public void Matches_ReturnsFalse_ForDifferentKey()
+ {
+ var key = new ApiKey("s3cret-value");
+ Assert.False(key.Matches("wrong"));
+ }
+
+ [Fact]
+ public void Matches_ReturnsFalse_ForKeyOfDifferentLength()
+ {
+ var key = new ApiKey("s3cret");
+ Assert.False(key.Matches("s3cret-value"));
+ Assert.False(key.Matches("s3cre"));
+ }
+
+ [Fact]
+ public void Matches_ReturnsFalse_ForNull()
+ {
+ var key = new ApiKey("s3cret");
+ Assert.False(key.Matches(null));
+ }
+
+ [Fact]
+ public void Matches_ReturnsFalse_ForEmptyCandidate_WhenKeyIsNotEmpty()
+ {
+ var key = new ApiKey("s3cret");
+ Assert.False(key.Matches(string.Empty));
+ }
+
+ [Fact]
+ public void Matches_IsCaseSensitive()
+ {
+ var key = new ApiKey("Secret");
+ Assert.False(key.Matches("secret"));
+ Assert.False(key.Matches("SECRET"));
+ }
+
+ [Fact]
+ public void Matches_HandlesMultiByteUtf8()
+ {
+ var key = new ApiKey("nyckel-äöå");
+ Assert.True(key.Matches("nyckel-äöå"));
+ Assert.False(key.Matches("nyckel-aoa"));
+ }
+
+ [Fact]
+ public void Constructor_ThrowsOnNull()
+ {
+ Assert.Throws(() => new ApiKey(null!));
+ }
+
+ [Fact]
+ public void Value_ReturnsConfiguredString()
+ {
+ var key = new ApiKey("abc");
+ Assert.Equal("abc", key.Value);
+ }
+
+ [Fact]
+ public void DefaultStruct_Value_IsEmpty()
+ {
+ var key = default(ApiKey);
+ Assert.Equal(string.Empty, key.Value);
+ }
+}