Security
This page is a map, not a manual. Warlock doesn’t ship a monolithic “security module” — instead, security shows up as a handful of focused pieces: crypto helpers for secrets, a suite of protective HTTP middleware, a few transport knobs in src/config/http.ts, and validation as a hard request boundary. This page links them together so you can see the whole surface at once, then sends you to the page that goes deep on each.
Nothing here is novel — it’s the honest inventory of what exists today, with each knob verified against the code that reads it. If a security feature isn’t listed here, assume it isn’t built.
The 30-second look
Section titled “The 30-second look”flowchart TB
client["Client request"]
subgraph edge["Edge / transport"]
cors["CORS (@fastify/cors)"]
reqid["X-Request-Id echo + inherit"]
cookies["signed cookies (@fastify/cookie)"]
end
subgraph mw["Protective middleware (per-route / app-wide)"]
maint["maintenance → 503 EC106"]
ipf["ipFilter → 403 EC105"]
rl["rateLimit → 429 EC102"]
cc["concurrencyLimit → 429 EC103"]
mbs["maxBodySize → 413 EC104"]
idem["idempotency → 422 EC100 / 400 EC101"]
end
valid["Validation (seal schema) → 400 errors"]
handler["Handler"]
crypto["Crypto at rest:<br/>hashPassword · encrypt/decrypt · hmacHash"]
client --> edge --> mw --> valid --> handler
handler -.->|reads/writes secrets| crypto
The layers, outside-in:
- Transport — CORS, request-id correlation, cookie signing. Configured in
src/config/http.ts. - Protective middleware — opt-in per route (or app-wide) gates that reject before your handler runs. Each emits a stable
HttpErrorCodesvalue. - Validation — the seal schema attached to a controller runs before the handler. A failed schema is a rejected request, full stop.
- Crypto at rest — when the handler stores a password or a secret, it reaches for the encryption helpers.
Secrets and crypto
Section titled “Secrets and crypto”Two rules carry most of the weight: never commit a secret, and pick the right crypto helper for the job.
Secrets live in .env, read through env()
Section titled “Secrets live in .env, read through env()”Every key, password, and credential comes from the environment, surfaced through env() and wired into a config file — never inlined in source.
import type { EncryptionConfigurations } from "@warlock.js/core";import { env } from "@warlock.js/core";
const encryptionConfig: EncryptionConfigurations = { key: env("APP_ENCRYPTION_KEY"), // 64 hex chars = 32 bytes hmacKey: env("APP_HMAC_KEY"), // falls back to `key` if absent password: { salt: 12, // bcrypt rounds },};
export default encryptionConfig;The three crypto helpers
Section titled “The three crypto helpers”All three live in @warlock.js/core. Mixing them up is the single most common security mistake — encrypting a password turns a database leak into a credentials leak; HMAC-ing one makes it brute-forceable.
| Helper | Direction | Algorithm | Use for |
|---|---|---|---|
hashPassword / verifyPassword | one-way | bcrypt (slow) | User-typed passwords. Only passwords. |
encrypt / decrypt | reversible | AES-256-GCM | Secrets you must read back — API keys, OAuth tokens. |
hmacHash | one-way | HMAC-SHA256 | Deterministic fingerprints for lookup of encrypted data. |
import { hashPassword, verifyPassword, encrypt, decrypt, hmacHash } from "@warlock.js/core";
const hashed = await hashPassword("user-password-123"); // store thisconst valid = await verifyPassword("user-password-123", hashed);
const cipher = encrypt("sk-proj-12345"); // iv:ciphertext:authTagconst plain = decrypt(cipher);
const fingerprint = hmacHash("sk-proj-12345"); // 64-char hex, deterministicFull treatment — config shape, the encrypt-plus-fingerprint pattern, key rotation as a migration, why bcryptjs over native bcrypt — lives on the Encryption page.
Protective middleware
Section titled “Protective middleware”Warlock ships six built-in middleware factories under the middleware namespace. They are opt-in — registered per-route (in a route’s middleware array) or app-wide (via http.middleware.all). None of them run unless you wire them in.
import { middleware, router } from "@warlock.js/core";
router.post("/auth/login", loginController, { middleware: [middleware.rateLimit({ max: 5, duration: 60_000 })],});Each rejection carries a stable HttpErrorCodes value so clients can branch on a code instead of parsing message text. The core range is EC100..EC199 (EC001..EC099 belongs to @warlock.js/auth).
| Factory | Rejects with | Error code | Caps / guards |
|---|---|---|---|
middleware.maintenance(…) | 503 + Retry-After | EC106 | App in maintenance mode; allowlist bypass (default ["/health"]). |
middleware.ipFilter(…) | 403 | EC105 | Allow / deny by IP or IPv4 CIDR. Fail-closed; deny wins over allow. |
middleware.rateLimit(…) | 429 + Retry-After | EC102 | Requests-per-window per group key (default: client IP). |
middleware.concurrencyLimit(…) | 429 + Retry-After: 1 | EC103 | In-flight request cap per group key (default: route path). |
middleware.maxBodySize(…) | 413 | EC104 | Rejects when Content-Length exceeds the per-route cap. |
middleware.idempotency(…) | 422 / 400 | EC100 / EC101 | Dedupes writes by Idempotency-Key; conflict vs. malformed key. |
A few facts worth internalizing before you lean on these:
rateLimitandconcurrencyLimitcounters are process-local. WithNreplicas the effective cap isN × max. For a genuinely shared rate limit, configure@fastify/rate-limit’s Redis store viahttp.rateLimitinstead; for shared concurrency, reach for a@warlock.js/cachelock.ipFilterreads the IP viarequest.detectIp(), which honorsX-Real-IP/X-Forwarded-Forbecause Fastify runs withtrustProxy: true. Those headers are client-settable — only trust them behind a proxy you control.maxBodySizeis a per-route layer, not pre-read protection. It short-circuits the application stack onContent-Length, but the server still reads the body off the wire. For true pre-read protection, lowerhttp.bodyLimitat the Fastify level too.idempotencymust run afterauthMiddleware— its cache key is scoped per-user so user A can’t replay user B’s key. Anonymous requests fall back to IP scope.maintenanceis toggled by config and needs a restart to flip — there’s no runtime hot-flip. Its allowlist defaults to["/health"]so health checks survive planned downtime.
The full error-code catalog and how rejections are shaped lives on the Error handling page.
The global rate limit is separate
Section titled “The global rate limit is separate”@fastify/rate-limit is always registered with a global cap read from http.rateLimit (max default 60, duration default 60_000ms). That global 429 is distinct from the per-route middleware.rateLimit() (EC102) — both can run, and either can reject. Use the global as a coarse backstop and the middleware for endpoints that need a tighter cap (login, OTP, password reset, AI completions).
Transport concerns
Section titled “Transport concerns”Three transport-layer knobs live in src/config/http.ts. All are optional, with defaults you should review before production.
import { env } from "@warlock.js/core";
export default { cors: { origin: env("APP_URL"), credentials: true, }, cookies: { secret: env("COOKIE_SECRET"), // enables signed cookies }, requestId: { header: "X-Request-Id", // default enabled: true, // default },};CORS comes from @fastify/cors, configured via http.cors (a FastifyCorsOptions).
Gotcha — the framework’s permissive default wins. The plugin is registered as
{ ...config.get("http.cors", {}), ...defaultCorsOptions }, anddefaultCorsOptionsis{ origin: "*", methods: "*" }. Because the default is spread last, it overrides yourorigin/methods— todayhttp.corscannot tighten those two fields through config. If you need a locked-down origin, that’s a known limitation to confirm against the currentsrc/http/plugins.tsbefore relying on it.
Cookies
Section titled “Cookies”@fastify/cookie is always registered. Set http.cookies.secret to enable signed cookies; http.cookies.options becomes the cookie parseOptions. Without a secret, cookies are parsed but unsigned — fine for non-sensitive values, not for anything a client shouldn’t be able to forge.
Request-id correlation
Section titled “Request-id correlation”Every request gets a request.id. The framework echoes it back as a response header (X-Request-Id by default) and inherits an incoming value of the same header when present, so a single id correlates client logs, server logs, and traces.
Inherited values are validated before use — non-empty printable ASCII, max 128 characters — to block log-injection from a malicious client (a forged newline in a header could otherwise corrupt your log stream). Knobs under http.requestId:
| Key | Default | Effect |
|---|---|---|
header | "X-Request-Id" | Inbound + outbound header name. |
generator | random 32-char | Override the id generator. |
enabled | true | Set false to stop echoing and inheriting. request.id is still generated for internal logging. |
Validation as a boundary
Section titled “Validation as a boundary”Validation is a security control, not just UX polish. A seal schema attached to a controller runs before the handler is ever called; a failed schema short-circuits with a 400 carrying an errors payload, and the handler never sees the malformed input. That makes the schema your type-and-shape boundary against the outside world — reject early, and the rest of your code operates on values you’ve already proven.
import { v } from "@warlock.js/seal";
export const createUser = async (request, response) => { // only runs if the schema below passed};
createUser.validation = { schema: v.object({ email: v.string().email().required(), password: v.string().min(8).required(), role: v.string().in(["client", "admin"]).required(), }),};Treat every externally-provided field as hostile until a validator has vouched for it. Constrain enums with v.string().in([...]) or v.enum([...]), cap lengths, and require what you depend on. The full schema surface — database-aware rules like unique / exists, file rules, the request-type alias, and ad-hoc validation — is on the Validation page.
Hardening checklist
Section titled “Hardening checklist”Concrete, verified-against-the-code steps. Only knobs that actually exist are listed.
- Secrets in
.env, never in source. Read them throughenv()into a config file. Keep.envout of version control. - Set
APP_ENCRYPTION_KEY(and ideally a separateAPP_HMAC_KEY). A missing key throws on the firstencrypt()call at runtime, not at boot — catch it in a startup pre-flight. Use a fresh 32-byte key (crypto.randomBytes(32).toString("hex")). - Hash passwords with
hashPassword, neverencryptorhmacHash. bcrypt only, on signup / login / password-change — never on the per-request hot path. - Encrypt recoverable secrets, fingerprint them with
hmacHashfor lookup. Look up by fingerprint; decrypt only at the moment of use. Never log a decrypted value. - Lock down CORS via
http.cors— but verify the permissive-default gotcha above againstsrc/http/plugins.tsfirst;origin/methodsmay not be overridable through config yet. - Sign cookies by setting
http.cookies.secretwhenever a cookie value must not be client-forgeable. - Lower
http.bodyLimitfrom the historical default for production, and addmiddleware.maxBodySize()on routes that accept user payloads. - Add
middleware.rateLimit()to login, OTP, password-reset, and other abuse-prone endpoints — tighter than the globalhttp.rateLimitbackstop. Use a Redis store viahttp.rateLimitif you run multiple replicas and need a shared cap. - Add
middleware.concurrencyLimit()to unbounded-cost endpoints (report generation, AI completions, image processing). - Gate admin / webhook routes with
middleware.ipFilter()— and only trustX-Forwarded-Forbehind a proxy you control. - Require
middleware.idempotency()after auth on non-idempotent writes (POST/PUT/PATCH/DELETE) that clients may retry. - Attach a validation schema to every controller that takes input. Constrain enums, lengths, and required fields. Reject malformed input before the handler runs.
- Keep
http.requestIdenabled so every request is traceable; inherited ids are already validated against log-injection.
See also
Section titled “See also”- Encryption — the three crypto helpers in full: config, the encrypt-plus-fingerprint pattern, key rotation, model-level boundaries.
- Error handling — the full
HttpErrorCodescatalog and how rejections are shaped. - Validation — authoring seal schemas, database-aware rules, and ad-hoc validation.
- Configuration —
env(), the config-file layout, and wheresrc/config/http.ts/src/config/encryption.tsplug in.