Fastify hooks: the lifecycle order that decides if your auth runs
A measured trace of every Fastify hook, which one your API key check belongs in, and what actually runs when a request never reaches your handler.

Sorower
Co-founder

In this article
- Every Fastify hook, in the order it actually runs
- What runs when the request never reaches your handler
- Does an error in my auth hook go through onError?
- The classic mistake: checking the key after the body is parsed
- Why does everyone put it in preHandler, then?
- When preHandler is the right answer
- Scope decides which routes a hook reaches, not declaration order
- Enforce early, meter late
- What test would have caught the 400 that should have been a 401?
- Key takeaways
- Skipping the whole argument
The bug report said "your API returns 400 for a bad API key." It did not. It returned 400 for a caller who sent no key at all, because the JSON body they posted was malformed, and Fastify rejected the body before our authentication code ever ran. The auth hook logged nothing. The failed-auth counter logged nothing. As far as the service was concerned, that request never happened.
That is not a Fastify bug. It is what you get when a Fastify hook holding your key check is registered one stage too late in the request lifecycle. In most frameworks, hook placement is a style question. In Fastify it decides whether your auth runs at all.
So I measured it. Everything below comes from probes on Fastify 5.11.3 and Node 26.0.0, and the output is pasted as it was printed.
Every Fastify hook, in the order it actually runs
Register all of them, log the order, send one well-formed POST:
const app = Fastify()
const trace = []
app.addHook('onRequest', async () => { trace.push('onRequest') })
app.addHook('preParsing', async (q, r, pay) => { trace.push('preParsing'); return pay })
app.addHook('preValidation', async () => { trace.push('preValidation') })
app.addHook('preHandler', async () => { trace.push('preHandler') })
app.addHook('preSerialization', async (q, r, pay) => { trace.push('preSerialization'); return pay })
app.addHook('onSend', async (q, r, pay) => { trace.push('onSend'); return pay })
app.addHook('onResponse', async () => { trace.push('onResponse') })
app.addHook('onError', async () => { trace.push('onError') })
app.post('/charge', { schema: bodySchema }, async () => {
trace.push('HANDLER')
return { ok: true }
})
POST /charge {"amount":10} [200]
onRequest -> preParsing -> preValidation -> preHandler -> HANDLER
-> preSerialization -> onSend -> onResponse
That much is in the official lifecycle docs. What the docs do not tell you is what is populated at each stage, which is the question you are really asking when you pick a hook for auth. Same request, printing the request object at every step:
| Hook | headers | params | query | body | routeOptions.url |
|---|---|---|---|---|---|
onRequest | yes | {"id":"42"} | {"dry":"1"} | undefined | /users/:id/charge |
preParsing | yes | {"id":"42"} | {"dry":"1"} | undefined | /users/:id/charge |
preValidation | yes | {"id":"42"} | {"dry":"1"} | object (unvalidated) | /users/:id/charge |
preHandler | yes | {"id":"42"} | {"dry":"1"} | object (validated) | /users/:id/charge |
onSend | yes | {"id":"42"} | {"dry":"1"} | object | /users/:id/charge |
onResponse | yes | {"id":"42"} | {"dry":"1"} | object | /users/:id/charge |
Read that table again, because it kills the most common excuse for putting auth late. Routing has already happened by the time onRequest fires. Path parameters, the query string, the headers and the route template are all there. The only thing missing at onRequest is request.body, and your API key is not in the body. It is in a header.
One nuance worth keeping: at preValidation the body exists but has not been checked against your JSON schema yet. If you authenticate there, you are reading an object an anonymous caller controls the shape of. At preHandler it has passed the schema.
What runs when the request never reaches your handler
This is the part nobody publishes, and it is the part that matters. Six scenarios against the same app, recording which hooks fired:
| Hook | 200 happy path | 400 schema | 400 bad JSON | 401 from a hook | 404 no route | 500 handler threw |
|---|---|---|---|---|---|---|
onRequest | ran | ran | ran | ran | ran | ran |
preParsing | ran | ran | ran | skipped | ran | ran |
preValidation | ran | ran | skipped | skipped | ran | ran |
preHandler | ran | skipped | skipped | skipped | ran | ran |
| handler | ran | skipped | skipped | skipped | skipped | ran |
preSerialization | ran | skipped | skipped | ran | ran | skipped |
onSend | ran | ran | ran | ran | ran | ran |
onResponse | ran | ran | ran | ran | ran | ran |
onError | skipped | ran | ran | skipped | skipped | ran |
Four things fall out of that grid.
A malformed body skips preValidation and preHandler entirely. Content-type parsing sits between preParsing and preValidation, so if the JSON does not parse, the lifecycle jumps straight to the error path. An auth hook in either of those two positions is simply not invoked.
Only onSend and onResponse ran in all six scenarios. If you are recording usage, those are the only two seams that see every request, including the ones that failed before your handler.
A 404 runs the full pre-handler chain. onRequest, preParsing, preValidation and preHandler all fire for a path no route matched. That is genuinely good news, and it makes Fastify unusual: a NestJS global guard never runs on an unmatched route, and an Axum route_layer is skipped the same way, which turns the 401-versus-404 split into a map of your routes. Fastify has no such oracle. An instance-level auth hook answers 401 for /definitely-not-a-route too.
preSerialization is skipped on a thrown error but runs on a 404. If you are rewriting response bodies there (adding a request ID, say), your 500s will not get the treatment.
Does an error in my auth hook go through onError?
Only if you throw. Calling reply.code(401).send(...) is not an error, so onError stays quiet and the trace goes straight from your hook to preSerialization and out. Throwing a custom error object instead routes through onError and then your setErrorHandler, which is what you want if error shaping lives in one place. Pick one and be consistent, because a mixed codebase produces two different 401 bodies.
The classic mistake: checking the key after the body is parsed
Now the expensive part. Two identical apps. The only difference is the hook name holding the key check. Same 1.81 MiB JSON payload, no API key sent:
const auth = async (req, reply) => {
stats.authRuns += 1
if (req.headers['x-api-key'] !== VALID) {
return reply.code(401).send({ error: 'invalid_api_key' })
}
}
app.addHook(HOOK, auth) // 'onRequest' in one app, 'preHandler' in the other
=== auth in onRequest === (payload 1.81 MiB)
no key 401 invalid_api_key auth=1 jsonParse=0 parsedBytes=0
no key, bad JSON 401 invalid_api_key auth=1 jsonParse=0 parsedBytes=0
no key, 9 MiB 401 invalid_api_key auth=1 jsonParse=0 parsedBytes=0
=== auth in preHandler === (payload 1.81 MiB)
no key 401 invalid_api_key auth=1 jsonParse=1 parsedBytes=1897791
no key, bad JSON 400 FST_ERR_CTP_INVALID_JSON_BODY auth=0 jsonParse=1 parsedBytes=18
no key, 9 MiB 413 FST_ERR_CTP_BODY_TOO_LARGE auth=0 jsonParse=0 parsedBytes=0
Look at auth=0 on the bottom two rows. A caller with no credentials whatsoever sent a broken body and got a 400. Sent an oversized body and got a 413. In both cases the authentication hook never executed. Which means:
- Your failed-auth counter did not increment, so the flood is invisible. We measured that exact blind spot in rate limiting failed authentication attempts, and this is a second way into it.
- Any rate limiter you mounted inside the auth path never saw the request either.
- The caller learns your body limit without holding a key.
And on the row that does return 401, your process buffered and ran JSON.parse over 1,897,791 bytes from an anonymous caller before deciding to reject them. That is not free:
1.81 MiB payload, no API key, 200 samples each after 40 warm-up
auth in onRequest 401 p50 0.76 ms p95 1.59 ms
auth in preHandler 401 p50 4.65 ms p95 5.48 ms
ratio p50: 6.08x
Same status code, same response body, six times the work. On a normal day nobody notices 4 ms. On the day someone points a scanner at you, that ratio is the difference between shrugging and paging someone.
Why does everyone put it in preHandler, then?
Because the ecosystem's examples do. Every example in the @fastify/auth README wires strategies in as preHandler: fastify.auth([...]). Fastify's own hooks reference, in its section on route-level hooks, says that if you need to implement authentication then "the preParsing or preValidation hooks are exactly what you need."
Neither is wrong in context. Route-level hooks are declared inside a route you already matched, and @fastify/auth composes several strategies, some of which legitimately need a parsed body. But copying the shape without the context is how a header check ends up behind a JSON parser.
When preHandler is the right answer
Three cases, and they are real:
- The credential is in the body. Some legacy APIs accept a token as a body field. You cannot read it before parsing, so
preHandlerit is. - The check is authorization, not authentication. "Does this consumer own the resource named in the payload?" needs the validated payload.
- The rule depends on the schema having passed. If a 400 would make the authorization question meaningless, run after validation.
The clean split is to stop treating them as one step. Authenticate in onRequest: who is calling, is the key live, do they have credits. Authorize in preHandler: may this caller do this specific thing to this specific object. The first question never needs a body. The second usually does.
Scope decides which routes a hook reaches, not declaration order
The second half of "does my auth run" has nothing to do with the lifecycle. Fastify's hooks reference puts it plainly: except for onClose, "all hooks are encapsulated." A hook reaches the scope it was added to and everything below it, and nothing else.
Here is a boot sequence with three root hooks, one plain plugin, one fastify-plugin wrapper, and route-level options:
app.addHook('onRequest', mark('root#1'))
app.addHook('onRequest', mark('root#2'))
app.get('/early', { onRequest: mark('route-option') }, handler) // declared here
app.addHook('onRequest', mark('root#3-added-after-the-route')) // added after it
app.register(async (child) => { // plain plugin: encapsulated
child.addHook('onRequest', mark('plain-plugin'))
child.get('/inside', handler)
})
app.register(fp(async (parent) => { // fastify-plugin: breaks out
parent.addHook('onRequest', mark('fp-plugin'))
}))
app.register(async (sibling) => { sibling.get('/sibling', handler) })
GET /early [200] root#1, root#2, root#3-added-after-the-route, fp-plugin, route-option
GET /inside [200] root#1, root#2, root#3-added-after-the-route, plain-plugin, fp-plugin
GET /sibling [200] root#1, root#2, root#3-added-after-the-route, fp-plugin
GET /not-a-route [404] root#1, root#2, root#3-added-after-the-route, fp-plugin
Three rules, all visible in that output:
Declaration order does not matter within a scope. root#3 was added after /early was declared and still guards it. This is the opposite of Express, where app.use order is everything.
A plain plugin's hook guards only its own subtree. plain-plugin appears on one line out of four. If that plugin is your auth plugin, three of those four routes are open, and nothing warns you. That failure mode is the subject of Fastify API key authentication: the hook that guards nothing, which has the full 401-versus-200 receipt and the boot-time sweep that catches it.
Route-level hooks run last. Every instance-level onRequest fires before the route's own onRequest option. That is not trivia. @fastify/rate-limit attaches through an onRoute hook, which makes it route-level, which means a global auth hook runs before it and every rejected key bypasses the limiter completely. We measured 500 guessed keys against a 3-per-minute limit and got zero 429s in Fastify rate limiting: what @fastify/rate-limit doesn't do for you.
If you want the encapsulation model from the person who designed it, this Node Congress talk is the clearest explanation of it I have found:
Enforce early, meter late
The failure matrix answers the metering question too. A usage counter in onRequest charges for requests Fastify itself rejects: a schema failure fires onRequest and never reaches your handler, so you have billed a credit for work nobody did. onSend and onResponse are the two hooks that ran in every scenario, and by then you know the status code.
app.decorateRequest('consumer', null)
// enforce here: no body has been read yet
app.addHook('onRequest', async (req, reply) => {
const key = req.headers['x-api-key']
if (!key) return reply.code(401).send({ error: 'missing_api_key' })
let res
try {
res = await fetch('https://api.reqkey.com/key/validate', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.REQKEY_PROJECT_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ key, credits: 1 }),
signal: AbortSignal.timeout(2000)
})
} catch (err) {
req.log.error({ err }, 'key validation unreachable')
return reply.code(503).send({ error: 'auth_unavailable' }) // fail closed
}
if (!res.ok) {
if (res.status === 402) return reply.code(402).send({ error: 'insufficient_credits' })
if (res.status === 403) return reply.code(403).send({ error: 'access_denied' })
if (res.status === 429) return reply.code(429).send({ error: 'rate_limited' })
req.log.error({ status: res.status }, 'key validation failed')
return reply.code(503).send({ error: 'auth_unavailable' })
}
// an unknown key comes back 200 with valid:false, so branch on the field
const decision = await res.json()
if (!decision.valid) return reply.code(401).send({ error: 'invalid_api_key' })
req.consumer = decision
reply.header('X-Credits-Remaining', decision.creditsRemaining)
})
// meter here: every request reaches this, and the status code is known
app.addHook('onResponse', async (req, reply) => {
if (!req.consumer || reply.statusCode >= 400) return
metrics.record({
route: req.routeOptions.url ?? req.url, // undefined on a 404
status: reply.statusCode,
ms: reply.elapsedTime
})
})
Three details in there are worth stealing. The catch block decides fail-open versus fail-closed explicitly instead of letting a timeout become a 500; if you have not made that call deliberately, your limiter has made it for you. The rejection branches on why the key failed, because "out of credits" (402) and "too fast" (429) are not "wrong key" (401), and a client that retries a 402 will retry forever. And req.routeOptions.url gives you the route template rather than the raw path, so /users/12345 and /users/67890 land in one series instead of two. It is undefined on an unmatched route, hence the fallback.
What test would have caught the 400 that should have been a 401?
Not a happy-path test. Assert the shape of the rejection for every way a request can arrive broken:
test('unauthenticated requests are rejected before parsing', async (t) => {
const cases = [
['well formed', JSON.stringify({ amount: 10 })],
['malformed', '{"amount": broken'],
['oversized', JSON.stringify({ blob: 'x'.repeat(9 * 1024 * 1024) })],
['wrong schema', JSON.stringify({ nope: true })]
]
for (const [name, payload] of cases) {
const res = await app.inject({
method: 'POST', url: '/charge', payload,
headers: { 'content-type': 'application/json' } // deliberately no key
})
t.equal(res.statusCode, 401, `${name} -> 401`)
}
})
Run it against both placements and the difference is the whole article in two lines:
auth in onRequest well formed=401 malformed=401 oversized=401 wrong schema=401 (401s: 4/4)
auth in preHandler well formed=401 malformed=400 oversized=413 wrong schema=400 (401s: 1/4)
Three of the four rows go red, and the one that passes is the only shape a happy-path test would ever have sent. That is the point: the assertion is not "auth works," it is "auth runs first."
Key takeaways
- Put header-based authentication in
onRequest. Routing has already happened, so params, query, headers and the route template are all available. The only thing you give up isrequest.body, and your API key is not in it. - Auth after the parser is auth that sometimes does not run. A malformed or oversized body skips
preValidationandpreHandler, so an anonymous caller gets a 400 or a 413 with your auth hook never executing and your failed-auth metrics never incrementing. - Rejecting late costs about six times as much. A 1.81 MiB unauthenticated POST rejected in
onRequesttook a 0.76 ms median; the same rejection frompreHandlertook 4.65 ms, because the body was buffered and parsed first. - Scope decides coverage, order decides sequence. Hooks are encapsulated, so an auth hook registered as a plain plugin guards only that plugin's subtree no matter where you declare it. Use
fastify-pluginor add it to the root instance. - Meter in
onSendoronResponse, never inonRequest. Those two are the only hooks that fire on every outcome, and they are the only ones that know the status code.
Skipping the whole argument
If you would rather not hand-place any of this, ReqKey's Node SDK ships a Fastify 5 plugin that makes the choices above for you: it enforces in onRequest, records usage in onSend, and sets Fastify's skip-override marker so registering it applies to the parent scope instead of quietly guarding nothing.
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',
keyName: 'X-API-Key',
excludePaths: ['/health']
})
app.post('/payments', async (request) => {
return { created: true, creditsRemaining: request.reqkey?.creditsRemaining }
})
Keys, per-consumer credits and request logs come with it, and the free tier is enough to run the four-case test above against a real key rather than a string constant. If you would rather keep your own hook and only outsource the lookup, the validate endpoint is one POST, exactly as shown earlier.



