Grimoire has two distinct authentication planes:
| Plane | Header | Who uses it | What it grants |
|---|---|---|---|
| Management | Authorization: Bearer <key> |
Admins / CI pipelines | Full CRUD on all resources |
| Consumer | X-Api-Key: <key> |
Application services | Read-only access to own secrets and configs |
The separation ensures that compromising a consumer API key does not expose management operations, and vice versa.
All secret values are encrypted before being written to the database. Configuration entries are not encrypted.
1
2
3
4
5
6
7
Encryption:MasterKey (from appsettings / env var)
│
▼
HKDF-SHA256 (info = "grimoire-aes-key", salt = none)
│
▼
256-bit AES key (cached in-process as singleton)
HKDF.DeriveKey (from System.Security.Cryptography) is used for key derivation. This means you can rotate the master key without losing old secrets if you re-encrypt the database first.
1
2
3
4
5
6
7
8
9
10
11
plaintext (UTF-8 bytes)
│
▼
AesGcm.Encrypt(
nonce = 12 random bytes (RFC 5116),
key = derived 256-bit key,
tagSize = 16 bytes
)
│
▼
stored = Base64( nonce[12] ∥ tag[16] ∥ ciphertext )
1
2
3
4
5
6
7
8
9
10
stored Base64
│
▼
split: nonce[0..12], tag[12..28], ciphertext[28..]
│
▼
AesGcm.Decrypt(nonce, ciphertext, tag)
│
▼
plaintext
The 16-byte authentication tag means any tampering with the ciphertext or nonce produces an AuthenticationTagMismatchException rather than silently returning corrupt data.
| Operation | Location |
|---|---|
| Encrypt | SecretsController.SetValues() (Management API write path) |
| Decrypt | ConsumerSecretsController.GetSecret() (Consumer API read path) |
The raw ciphertext is never returned by the Management API. The Management API only returns metadata (version number, enabled flag, timestamps).
Consumer API keys follow this lifecycle:
1
2
3
4
5
6
7
8
9
10
CreateApplication()
│
▼
RandomNumberGenerator.GetBytes(32)
│
▼
Convert.ToHexString → "grm_{40 hex chars}" ← plainApiKey (shown once)
│
▼
IPasswordHasher<Application>.HashPassword() ← PBKDF2 hash stored in DB
1
2
3
4
5
6
7
8
9
10
X-Api-Key header
│
▼
Load all Application rows from DB
│
▼
foreach app:
IPasswordHasher.VerifyHashedPassword(app.ApiKeyHash, rawKey)
→ match? → store app in HttpContext.Items → continue
→ no match? → 401
The linear scan across applications is acceptable at small scale. For deployments with hundreds of applications, consider adding a fast lookup index (e.g. storing the first 8 bytes of a SHA-256 hash as a lookup hint).
1
POST /api/management/applications/{slug}/rotate-key
grm_ prefixed keyApiKeyHash in the databaseThere is no grace period. Schedule rotations during low-traffic windows or use a proxy that forwards requests with both old and new keys during the cutover.
AdminApiKeyMiddleware/api/management/*Authorization: Bearer <token> headerManagement:AdminApiKey from configuration using string.Equals(..., OrdinalIgnoreCase)401 and short-circuits if missing or mismatchedConsumerApiKeyMiddleware/api/consumer/*X-Api-Key header401 and short-circuits on failureApplication entity to HttpContext.Items| Item | Recommended action |
|---|---|
Management:AdminApiKey |
Use a random 40+ character string; store in a secrets manager |
Encryption:MasterKey |
Use a random 32+ character string; back it up securely |
| SQLite file | Mount on an encrypted volume; restrict file permissions |
| HTTPS | Place Grimoire behind a TLS-terminating reverse proxy (nginx, Caddy, etc.) |
| CORS | Restrict Cors:AllowedOrigins to known frontend origins |
| Log files | Do not log request bodies (Serilog config does not enable this by default) |