Fastify API key authentication: the hook that guards nothing
Registering your API key check as a Fastify plugin can protect nothing outside that plugin. Three traps measured on Fastify 5.11.2, and what to do about each.

Sorower
Co-founder

In this article
- The Fastify API key authentication snippet everyone ships
- Trap 1: your auth hook has a scope, and it isn't the one you think
- How to actually check
- What breaks at customer two
- Trap 2: the rate limiter runs after your auth hook
- The fix: two limiters, one before identity and one after
- The other half: shared IPs still bite
- Trap 3: metering in onRequest bills for requests Fastify rejects
- Questions you'll actually hit
- Skipping the whole thing
- Key takeaways
You did the Fastify thing. You didn't dump an onRequest hook in the middle of server.js like some Express refugee. You wrote a proper plugin, gave it a name, registered it at the top of the file, and moved on feeling good about your architecture.
Then someone curls a route without a key and gets a 200.
This is the part of Fastify API key authentication that no tutorial covers, because every tutorial has exactly one file. Here is the whole failure, reproduced on Fastify 5.11.2 and Node 26:
// auth.js — the plugin every guide tells you to write
export default async function authPlugin (fastify, opts) {
fastify.addHook('onRequest', async (req, reply) => {
if (req.headers['x-api-key'] !== process.env.API_KEY) {
return reply.code(401).send({ error: 'unauthorized' })
}
})
}
// server.js
app.register(authPlugin)
app.get('/root-route', async () => ({ ok: true }))
app.register(async function billingRoutes (scope) {
scope.get('/billing/charge', async () => ({ ok: true }))
})
Sending no API key at all:
register(authPlugin) {"/root-route":200,"/billing/charge":200}
register(fp(authPlugin)) {"/root-route":401,"/billing/charge":401}
addHook at root directly {"/root-route":401,"/billing/charge":401}
The first line is your production server. Both routes, wide open, and every test you wrote passed because your tests live inside the same plugin scope that the hook actually protects.

Three traps, all measured below. The first one opens your API. The second one leaves your auth path unlimited. The third one bills customers for requests you rejected. None of them throw an error.
The Fastify API key authentication snippet everyone ships
Before the traps, credit where it's due: the snippet at the top is fine. If you have one key, in one env var, for one internal service, ship it and go do something that makes money. Comparing a header to process.env.API_KEY is not a security failure. It's a scope decision.
The SERP for this keyword is a wall of that snippet with "don't do this in production" bolted on the end and no next paragraph. The fastify-api-key plugin goes further than most (it does HMAC request signing against a draft IETF spec, with replay protection via a request-lifetime check) but it still hands you a getSecret callback and wishes you luck on storage, revocation and quotas.
So let's write the next paragraph. Actually, let's write four.
Trap 1: your auth hook has a scope, and it isn't the one you think
Fastify's encapsulation reference says it plainly: it "governs which decorators, registered hooks, and plugins are available to routes," and child contexts inherit from parents, never the other way around. Every register() call creates a child context. A hook added inside that child applies to the child and its descendants. It does not apply to the parent, and it does not apply to the parent's other children.
Which means app.register(authPlugin) creates a sealed room, puts your guard in it, and then routes traffic past the door.
This is not a bug, and it's the same property that makes Fastify good. Encapsulation is why you can register two different database connections in two subtrees without them fighting, and why a plugin can't quietly stomp on a decorator somewhere else in your app. It just happens to be catastrophic for the one plugin whose entire job is to apply everywhere.
The fix is fastify-plugin, which sets a skip-override marker telling Fastify not to create a child context:
import fp from 'fastify-plugin'
export default fp(async function authPlugin (fastify, opts) {
fastify.addHook('onRequest', async (req, reply) => {
if (req.headers['x-api-key'] !== process.env.API_KEY) {
return reply.code(401).send({ error: 'unauthorized' })
}
})
}, { name: 'auth', fastify: '5.x' })
Now the hook lands on the parent instance and covers everything registered under it. Two things worth knowing that follow from this:
Registration order does not matter at the root. Unlike Express, where app.use order is everything, a hook added on an instance applies to every route on that instance regardless of which line declared it first. Verified: a route declared before addHook and one declared after both returned 401. Scope is the axis, not order. If you came from Express and internalised "auth middleware goes first," you learned the right lesson on the wrong framework, and the habit will not save you here.
Unmatched routes are covered too. An instance-level onRequest hook runs before routing resolves, so a request to /definitely-not-a-route with no key returned 401, not 404. That's the good outcome. It means an unauthenticated stranger can't separate your real routes from imaginary ones by watching status codes, which is exactly the leak we measured in NestJS, where a global guard never runs on unmatched routes and the 403-vs-404 split becomes a route-existence oracle. Fastify gets this right by default. Enjoy it.
How to actually check
Don't reason about your plugin tree. Ask it. Fastify fires an onRoute hook every time a route is registered anywhere beneath the instance, including inside encapsulated children, so you can collect the whole route table at boot and then sweep it:
import { test } from 'node:test'
import assert from 'node:assert'
import { buildApp } from '../src/app.js'
function collectRoutes (app) {
const routes = []
app.addHook('onRoute', (r) => {
for (const method of [].concat(r.method)) {
if (method === 'HEAD' || method === 'OPTIONS') continue
routes.push({ method, url: r.url })
}
})
return routes // filled during boot, read after ready()
}
test('no route answers 200 without a key', async () => {
const app = Fastify()
const routes = collectRoutes(app) // attach the collector BEFORE registering anything
await buildApp(app)
await app.ready()
for (const route of routes) {
const res = await app.inject({ method: route.method, url: route.url })
assert.strictEqual(res.statusCode, 401, `${route.method} ${route.url} is not protected`)
}
})
Pointed at the unwrapped plugin from the top of this post, it fails exactly as it should:
discovered routes: [{"method":"GET","url":"/things"},
{"method":"POST","url":"/things"},
{"method":"GET","url":"/billing/charge"}]
unprotected routes: [ 'GET /things -> 200',
'POST /things -> 200',
'GET /billing/charge -> 200' ]
Add fp() and it reports none. The assertion is the point: no key, no 200, anywhere. Every framework in this series has its own way to accidentally skip auth, and a sweep catches all of them without you having to understand any of them.
What breaks at customer two

The env-var comparison survives exactly one customer. Here's the order things fall over, and it's the same order in every language:
Storage. Two customers means two keys, which means a table, not an env var. And the table stores a hash, not the key. Use SHA-256 with a pepper, not bcrypt: bcrypt salts every call randomly, so you cannot index the column and verification degrades into scanning every row. We measured that at 40.21 ms per row against 0.25 µs for an indexed SHA-256 lookup, plus a 72-byte truncation bug that makes two different keys verify as the same one. The full argument and the reproduction are here.
Lookup. Hashing kills your WHERE key = ?. You need a lookup path that doesn't scan: store a short non-secret prefix alongside the hash, index the prefix, then compare hashes with a constant-time function. This is why every key you've ever pasted starts with sk_live_ or similar.
Revocation. A customer emails you at 23:40 saying a key is in a public repo. Your answer is a database write and however long your caches hold the old value. If you have no revocation path, your answer is a deploy.
Identity. The interesting one. A validated key is not a boolean, it's a who. Attach it to the request and every downstream concern (logging, quotas, per-tenant limits, support forensics) becomes possible:
fastify.decorateRequest('customer', null)
fastify.addHook('onRequest', async (req, reply) => {
const presented = req.headers['x-api-key']
if (!presented) return reply.code(401).send({ error: 'missing api key' })
const customer = await lookupByKey(presented) // prefix index + constant-time compare
if (!customer) return reply.code(401).send({ error: 'invalid api key' })
if (customer.revokedAt) return reply.code(401).send({ error: 'key revoked' })
req.customer = customer
})
Note what the error branches don't do: they don't tell the caller which part was wrong. "Invalid api key" for a bad key and for a revoked key is deliberate. Also note there's no WWW-Authenticate header, because Fastify doesn't add one and neither did we. If you're returning 401, the HTTP spec says you owe the client a challenge header. Almost nobody sends it. At minimum, send WWW-Authenticate: Bearer and make your 401 honest.
Trap 2: the rate limiter runs after your auth hook

This one genuinely surprised me, because it's the opposite of every other framework we've tested.
In Express, Django REST Framework, Laravel and NestJS, the throttle runs before your auth layer. That's a problem we've written about at length: the limiter has no identity to bucket on yet, so it falls back to IP, and two customers behind one NAT share a bucket.
Fastify inverts it. @fastify/rate-limit attaches its check through an onRoute hook, meaning the limiter becomes part of each route's hook chain. Instance-level hooks run before route-level hooks. So your global auth hook runs first, and the limiter runs second:
order on a VALID request : auth -> rate-limit
order on an INVALID request : auth <- limiter never ran
Read that second line again. On a request with a bad key, the rate limiter does not execute at all. Your auth hook short-circuits with a 401 and the limiter never gets a turn.
The good news is real: you can bucket on the validated customer, because by the time the limiter runs, req.customer exists. That's the correct design, and Fastify hands it to you for free.
The bad news is a hole. Here is a 3-requests-per-minute limit, one IP, 500 requests with 500 different guessed keys:
500 key guesses from one IP : {"401":500}
Five hundred guesses. Zero 429s. Your rate limit did not fire once, because a request that fails auth never reaches the thing that counts requests. Every one of those 500 attempts ran your full key-lookup path: a database query, or worse, a network call to a validation service. Your limiter protects your handlers and leaves your auth path completely exposed.
The fix: two limiters, one before identity and one after
Turn off the global limiter, hoist a cheap IP-keyed one in front of auth, and put the real per-customer limit behind it:
await app.register(rateLimit, { global: false })
// 1. Coarse, IP-keyed, runs before we know anything. Protects the auth path.
app.addHook('onRequest', app.rateLimit({ max: 60, timeWindow: '1 minute' }))
// 2. Auth resolves identity.
app.addHook('onRequest', authenticate)
// 3. Fine, customer-keyed, runs after. This is the limit you sell.
app.addHook('onRequest', app.rateLimit({
max: 1000,
timeWindow: '1 minute',
keyGenerator: (req) => req.customer.id
}))
Same 500-guess flood against the hoisted version, with max: 3 to make it visible:
before: {"401":500}
after : {"401":3,"429":17}
Three attempts, then the door closes. That's the behaviour you assumed you already had.
One warning while you're in here. Do not be tempted to key the coarse limiter on the raw API key header. It looks like the obvious fix and it's strictly worse than IP: an attacker sends a different garbage key every time, mints a fresh bucket per request, and never trips anything. Bucket on IP before auth, on the validated customer after auth, and never on an unvalidated header.
The other half: shared IPs still bite
Fastify's ordering doesn't rescue you if you leave the limiter global and key it on IP. Two paying customers, two valid keys, one shared egress IP, max: 3:
alpha #1 200 x-ratelimit-remaining: 2
alpha #2 200 x-ratelimit-remaining: 1
alpha #3 200 x-ratelimit-remaining: 0
bravo #1 429 retry-after: 60 <- bravo's first request ever
Bravo has never called your API and is already throttled. And the reverse configuration is no better: turn on trustProxy without a proxy in front, and a client that rotates X-Forwarded-For gets six for six, remaining stuck at 2 every time, limit permanently bypassed. Both settings are wrong; only the identity you validated yourself is right.
While you're looking at that output, note what @fastify/rate-limit sends on a 429:
x-ratelimit-limit: 1
x-ratelimit-remaining: 0
x-ratelimit-reset: 60
retry-after: 60
{"statusCode":429,"error":"Too Many Requests","message":"Rate limit exceeded, retry in 1 minute"}
Retry-After is there, which already puts it ahead of most implementations. The x- prefixed trio is the old convention rather than the structured RateLimit field the IETF has been standardising, so if you care about clients auto-adapting, send both.
Trap 3: metering in onRequest bills for requests Fastify rejects
You resolved the customer in onRequest. So that's where you increment the usage counter, right? You know who they are, the request is authenticated, count it.
Except Fastify's JSON schema validation runs after onRequest. One valid request and one request with a body that fails the route schema:
valid body -> 200
invalid body -> 400 {"statusCode":400,"code":"FST_ERR_VALIDATION",
"error":"Bad Request",
"message":"body must have required property 'amount'"}
{ onRequestCharges: 2, preHandlerCharges: 1, onResponseCharges: 1, handlerRuns: 1 }
Two charges. One unit of work. The customer sent a malformed body, Fastify rejected it before your handler ever ran, and your meter billed them anyway.
Nobody notices this in a demo. You notice it when a customer's retry loop ships a malformed payload overnight and their invoice charges for every attempt, none of which your API served. That email is expensive, and you will not win the argument, because they're right.
The rule that falls out of this is the same one in every framework we've probed: enforce early, meter late. Enforcement needs to happen before any work is done. Metering needs to happen after you know work happened.
Fastify's lifecycle gives you clean seams for both:
| Hook | Runs | Use it for |
|---|---|---|
onRequest | Before body parsing and schema validation | Auth, rejection, coarse limits |
preValidation | After parsing, before schema validation | Body rewriting |
preHandler | After schema validation passes | Per-customer limits, reservations |
onSend | With the serialised payload in hand | Response headers, payload inspection |
onResponse | After the response is sent | Metering, usage logging, analytics |
Meter in onResponse and gate on the status code:
fastify.addHook('onResponse', async (req, reply) => {
if (!req.customer) return // never authenticated, nothing to bill
if (reply.statusCode >= 400) return // we didn't do the work, don't charge
await meter.record({
customerId: req.customer.id,
route: req.routeOptions?.url ?? req.url, // the template, not /users/12345
credits: creditsFor(req.routeOptions?.url),
statusCode: reply.statusCode
})
})
Two details in there earn their keep. req.routeOptions.url gives you the route template, so your analytics group by /users/:id instead of exploding into one series per user ID. And the >= 400 gate is a policy decision you should make on purpose rather than by accident: a 500 is your fault and should never be billed, while a 404 is genuinely arguable. We took the full swing at that question in the post on billing before you know the request worked.
Questions you'll actually hit
Does fastify-plugin break encapsulation everywhere, or just for hooks? Everywhere in that plugin. Decorators, hooks and nested registrations all land on the parent scope. That's the point, and it's why you should wrap auth, database connections and config in fp(), and leave route modules unwrapped so they stay isolated.
Can I protect only some routes? Yes, and encapsulation is finally your friend. Register the auth plugin unwrapped inside a subtree and every route in that subtree is covered while the rest of the app isn't. Or use route-level onRequest config for one-offs. The trap is only a trap when you meant "everywhere" and got "here."
What about excludePaths for health checks? Whatever you use to skip auth for /health, check whether it matches the path before or after any prefix rewriting. We got bitten by exactly this in Express, where the SDK normalises from req.originalUrl (full path) while a hand-rolled skip list sees the stripped one. Write the test.
Should validation fail open or fail closed if my key store is down? Neither answer is universal, which is why it has to be a config flag you set deliberately rather than a default you inherit. If your API is a payment rail, fail closed. If it's a read-heavy content API, an outage that 401s every paying customer is worse than a few minutes of unmetered traffic. Pick before the incident, not during it.
If you want the wider context on Fastify's plugin model (which is the root cause of trap 1 and worth understanding properly), Matteo Collina's Node Congress talk is the clearest explanation of encapsulation as a design choice rather than a gotcha:
Skipping the whole thing
Everything above is roughly two weeks of work: a key table, hashed storage, prefix indexing, a revocation path, two limiters, a metering hook, and the tests that stop all of it regressing. It's a genuinely good two weeks if key management is your product. It's a bad two weeks if it isn't.
ReqKey's Fastify plugin is the managed version. It registers like any other plugin and, notably, sets skip-override itself, so trap 1 can't bite you here:
npm install reqkey fastify
import Fastify from 'fastify'
import reqkey from 'reqkey/fastify'
const app = Fastify()
await app.register(reqkey, {
projectKey: process.env.REQKEY_PROJECT_KEY,
apiId: 'api_payments',
mode: 'both',
keyName: 'X-YourAPI-Key',
excludePaths: ['/health']
})
app.post('/payments', async (request) => {
const decision = request.reqkey // the verification result
return { created: true, creditsRemaining: decision?.creditsRemaining }
})
await app.listen({ port: 3000 })
Underneath it's one call you could make yourself:
curl -X POST "https://api.reqkey.com/key/validate" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{"key":"YourAPI_A1B2C3...","credits":1,"resource":"/payments"}'
{
"valid": true,
"requestId": "abc123xyz",
"creditsRemaining": 9995,
"creditsLimit": 10000,
"allowedApis": ["api_payment", "api_analytics"]
}
Credits and rate limits are separate axes: credits meters how much, a consumer's rateLimit meters how fast, and a 429 costs no credits so a throttled client recovers by slowing down. Exhausted credits return 402, a disabled key or consumer returns 403, and an unknown key returns 200 with {"valid": false} so you can tell "wrong key" from "out of money" without parsing strings. The full matrix is in the error reference, and the Node adapter options are on the Node SDK page.
Two honest caveats. Default mode: 'both' validates the key and logs the request, and our pricing counts each as a request, so one incoming call costs two. The free tier's 100,000 requests per month is therefore about 50,000 protected calls with analytics on, or the full 100,000 with mode: 'validate'. Better said here than discovered on an invoice. Second, a validation call is a network hop, so budget for it: it buys you revocation that propagates without a deploy, which a local hash lookup can't do.
Key takeaways
- Wrap your auth plugin in
fastify-plugin, or it protects nothing outside itself. A plainregister()creates a child context, and the hook stays in it. Two routes returned200with no key in the measurement above. Fix it in one import today. - Sweep every route for a 401 in CI. Scope bugs are invisible to unit tests written inside the scope. Enumerate routes with an
onRoutehook at boot and assert that no key means no200, anywhere. - Hoist a coarse IP limiter in front of auth.
@fastify/rate-limitattaches per route, so it runs after your auth hook and never sees rejected requests. Five hundred key guesses from one IP produced zero 429s. Bucket on IP before auth, on the validated customer after, never on the raw header. - Meter in
onResponse, notonRequest. Schema validation runs afteronRequest, so an early counter charged twice for one unit of work. Enforce early, meter late, and gate on the status code. - Send
WWW-Authenticateon your 401s. Fastify doesn't add it and neither does any tutorial. It costs one line and makes your 401 mean what the spec says it means.
If you'd rather spend the two weeks on your actual API, the key management layer is the part we run so you don't have to, and the free tier is large enough to put a real Fastify service behind it and watch the numbers move before you decide anything.



