ALL POSTS
fastifynode.jsmiddlewareapi keys

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

Sorower

Co-founder

Aug 11, 202613 min read
In this article

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.

Five-step diagram of a Fastify request: request arrives, body parsed, schema checked, handler runs, response sent, with a key icon on the first step

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:

HookheadersparamsquerybodyrouteOptions.url
onRequestyes{"id":"42"}{"dry":"1"}undefined/users/:id/charge
preParsingyes{"id":"42"}{"dry":"1"}undefined/users/:id/charge
preValidationyes{"id":"42"}{"dry":"1"}object (unvalidated)/users/:id/charge
preHandleryes{"id":"42"}{"dry":"1"}object (validated)/users/:id/charge
onSendyes{"id":"42"}{"dry":"1"}object/users/:id/charge
onResponseyes{"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:

Four cards showing four ways a request can end: bad key, broken body, too large, no route
Hook200 happy path400 schema400 bad JSON401 from a hook404 no route500 handler threw
onRequestranranranranranran
preParsingranranranskippedranran
preValidationranranskippedskippedranran
preHandlerranskippedskippedskippedranran
handlerranskippedskippedskippedskippedran
preSerializationranskippedskippedranranskipped
onSendranranranranranran
onResponseranranranranranran
onErrorskippedranranskippedskippedran

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:

Two cards comparing checking the key first, where nothing is parsed, against checking late, where the body is parsed anyway
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 preHandler it 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:

Matteo Collina explaining Fastify plugins and encapsulation at Node Congress 2023

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 is request.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 preValidation and preHandler, 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 onRequest took a 0.76 ms median; the same rejection from preHandler took 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-plugin or add it to the root instance.
  • Meter in onSend or onResponse, never in onRequest. 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.

Share this post

Put your API keys on autopilot.

Keys, credits, plans, and real-time traffic analytics — free for your first 100k requests a month.