Security
Reads are public, writes require a key
The API deliberately splits every resource into a public read surface and a protected write surface:
| Method | Route | Auth required? |
|---|---|---|
GET |
/api/bookmarks, /api/bookmarks/{id} |
No |
POST |
/api/bookmarks |
Yes |
PUT |
/api/bookmarks/{id} |
Yes |
DELETE |
/api/bookmarks/{id} |
Yes |
GET |
/api/folders, /api/folders/{id} |
No |
POST |
/api/folders |
Yes |
PUT |
/api/folders/{id} |
Yes |
DELETE |
/api/folders/{id} |
Yes |
GET |
/api/tags |
No |
GET |
/health |
No |
This is enforced per-route in the endpoint definitions, not globally: each mutating route chains .RequireAuthorization(), while GET routes and /health do not.
1
2
3
4
5
group.MapPost("/", async (BookmarkRequest request, BookmarkService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(request, ct);
return Results.Created($"/api/bookmarks/{created.Id}", created);
}).RequireAuthorization();
How the API key is validated
ApiKeyAuthenticationHandler (scheme name ApiKey, header X-Api-Key) runs on every request, but only rejects the request if the endpoint actually requires authorization:
- The configured key (
ApiKeyin configuration) must be non-empty, otherwise authentication always fails — write operations are unusable until an API key is configured. - The caller must send the
X-Api-Keyheader. - Both the configured key and the supplied key are SHA-256 hashed, then compared with
CryptographicOperations.FixedTimeEquals— a constant-time comparison that avoids leaking key length or content through response-timing side channels.
There is a single shared key (no per-client keys, no scopes) — anyone with the key can perform any write operation. Treat it as an administrative secret.
Error responses
Errors are mapped centrally by GlobalExceptionHandler into RFC 7807 ProblemDetails responses:
| Exception | Status | Body |
|---|---|---|
NotFoundException |
404 |
ProblemDetails |
ConflictException |
409 |
ProblemDetails (e.g. deleting a non-empty folder without cascade=true) |
AppValidationException |
400 |
ValidationProblemDetails with per-field errors |
| Anything else | 500 |
Generic ProblemDetails (details logged server-side, not exposed to the caller) |
CORS
Allowed browser origins are configured via Cors:AllowedOrigins (empty by default — no browser origin is allowed until explicitly configured). See Configuration.