User Guide
Security Model
What actually protects a secret between "create" and "get" — encryption, key hashing, access scoping, and what does (and doesn't) leave the service in logs and traces.
Encryption at rest
Every secret value is encrypted with Fernet (AES-128-CBC, HMAC-authenticated)
before it's written to the secrets table — the database only ever sees ciphertext.
By default the service's FERNET_KEY is used; a caller can instead supply a
fernet-key header per secret (BYOK) for additional isolation. A BYOK key is used
for that single request and is never stored by Airlock — lose it, and that secret is unrecoverable.
Access key hashing
Access keys are never stored in a reversible form. Each key is SHA-256 hashed, then that
digest is bcrypt-hashed (cost factor 12) for storage. A second, plain
SHA-256 hash of the key (acckey_lookup_hash) is stored purely as an index, so
validation is an indexed lookup plus one bcrypt comparison — not a bcrypt check against
every row in the table.
IP allowlisting
Each access key carries a semicolon-separated acckey_allowed_ips list.
0.0.0.0 disables IP checking for that key; anything else is an explicit allowlist.
Airlock resolves the caller's IP from cf-connecting-ip, then
x-forwarded-for, then the raw socket address — CIDR ranges aren't supported
natively, so put a reverse proxy or firewall in front if you need them.
Rate limiting
SlowAPI enforces per-IP limits before authentication even runs — RATE_LIMIT_READ
(60/minute default) and RATE_LIMIT_WRITE (5/minute default) — which caps how
fast an attacker can grind through access-key guesses.
Error sanitization
Every error message is checked against a small allowlist of known-safe strings (e.g.
"Secret not found", "Fernet key does not match") before it's returned
to the client. Anything that doesn't match is logged server-side in full and replaced with
a generic message plus a request ID — internal exception text, stack traces, and DB errors
never reach the caller.
Observability & secret scrubbing
Every request is traced via Pydantic Logfire. Logfire's default scrubbing already redacts
attribute paths containing words like secret, password, or
token — but that's a keyword match on the field name, not the value,
so it doesn't catch everything.
access_key and fernet_key fields don't
contain a default-scrubbed keyword, so they were showing up in plaintext in
traces. Airlock now sets explicit scrub patterns for all three sensitive fields:
logfire.configure(
service_name="airlock",
distributed_tracing=False,
scrubbing=logfire.ScrubbingOptions(
extra_patterns=["access_key", "fernet_key", "secret_name"]
),
)
Any matching field is replaced with [Scrubbed due to '<field>'] before export — the raw value never leaves the process.
Best practices
- Use strong, randomly generated access keys (32+ characters)
- Limit access keys to specific IPs when possible
- Rotate access keys periodically — and immediately if one may have leaked; create the replacement and revoke the old one yourself from the dashboard
- Use BYOK for your most sensitive secrets — it's the one case where you, not Network14.cloud, hold the key
- Review your Audit Log tab in the dashboard any time you want to check activity on your keys
- Always call the API over HTTPS/TLS (enforced by the service)