The Last Function Wins
A plugin for composing auth rules with AND and OR. The expression it evaluated wasn't always the one you wrote.
Authorization code is boolean algebra wearing a costume. Admin or (owner and same-tenant). Signed-in and (API key or session). You write the expression down, the expression is correct, and you move on — because the hard part was deciding the policy, and expressions are the easy part.
The easy part is where this one lived. The policy was right. The evaluator was wrong.
The boring part
@fastify/auth is the official Fastify plugin for composing authentication and authorization functions. You hand it an array, it runs them, and one of them failing is what turns into a 401. Its selling point is composition: you can nest arrays to build real expressions.
fastify.auth([f1, [f2, f3]], { relation: 'or' })
// documented meaning: f1 OR (f2 AND f3)
fastify.auth([[f1, f2], f3], { relation: 'and' })
// documented meaning: (f1 OR f2) AND f3
The outer relation joins the top-level elements; a nested array flips to the opposite relation. That’s a genuinely nice API — and inside or, or inside and, one level deep, no DSL to learn. About 420,000 downloads a month reach for it.
There’s one more option. By default, an or chain stops at the first function that succeeds. Pass { run: 'all' } and every function runs regardless, because — as the README says — “the run parameter is useful for adding business data read from auth tokens to the request.” People use it when their auth functions have side effects: decode the JWT, attach request.user, look up the tenant. Perfectly ordinary.
The tilt-your-head part
Look at how the verdict is stored. There’s one field on the shared state object:
this.currentError = null
One error slot, mutated from inside each function’s callback as the engine walks the array. And in the vulnerable version, several of those callbacks assign to it unconditionally:
that.processAuth(func, (err) => {
if (that.options.run !== 'all') that.currentError = err // <- last writer wins
...
That is the whole bug. currentError is not an accumulator, it’s a variable, and a boolean expression evaluated into a variable that every step overwrites doesn’t compute AND or OR. It computes whatever ran last.
The engine has latches for exactly this — skipFurtherErrors, skipFurtherArrayErrors, flags whose job is to freeze a decision so later steps can’t clobber it. They were set on some paths and not on others. The missing ones are the vulnerability.
Case 1 — a failing AND group that passes anyway
f1 OR (f2 AND f3) with run: 'all'. Inside the nested AND group, processAuthArray recurses:
} else { // sub-array relation is AND
if (err && that.options.run !== 'all') callback(err)
else that.processAuthArray(funcs, callback, err) // <- carries err forward...
}
Under run: 'all' a failure doesn’t short-circuit — by design, the remaining functions still need to run for their side effects. But the err it “carries forward” is immediately shadowed by the next function’s own err in the next callback. So f2 fails, f3 passes, and the group reports the last result: success. A request that satisfies only f3 has satisfied neither f1 nor f2 AND f3 — and gets a 200.
Case 2 — no run: 'all' required
(f1 OR f2) AND f3, default options. The OR group runs, both members fail, and the group correctly reports failure. The callback dutifully sets that.currentError = err.
Then the AND chain keeps going instead of stopping, reaches f3, and f3’s callback executes that.currentError = err with its own passing result. The recorded failure is gone. 200.
This is the one that matters most, because it needs no unusual options at all — just documented nesting on the default settings. Everything the OR group was guarding is simply not checked, as long as whatever comes after it says yes.
Proof
Three header-gated auth functions, the two documented compositions, and a request that supplies only f3 — a request that satisfies neither expression. Against the real npm tarballs, 5.1.0 and 5.1.1:
$ node poc.js ./v510/package/auth.js --- @fastify/auth 5.1.0 --- FAIL Case 1 f1 OR (f2 AND f3), run:'all' -- only f3 supplied headers={"x-f3":"yes"} expected=401 got=200 FAIL Case 2 (f1 OR f2) AND f3 -- only f3 supplied headers={"x-f3":"yes"} expected=401 got=200 PASS Control A f1 OR (f2 AND f3), run:'all' -- f2+f3 supplied headers={"x-f2":"yes","x-f3":"yes"} expected=200 got=200 PASS Control B (f1 OR f2) AND f3 -- f2+f3 supplied headers={"x-f2":"yes","x-f3":"yes"} expected=200 got=200 ==> BYPASS PRESENT
$ node poc.js ./v511/package/auth.js --- @fastify/auth 5.1.1 --- PASS Case 1 f1 OR (f2 AND f3), run:'all' -- only f3 supplied headers={"x-f3":"yes"} expected=401 got=401 PASS Case 2 (f1 OR f2) AND f3 -- only f3 supplied headers={"x-f3":"yes"} expected=401 got=401 PASS Control A f1 OR (f2 AND f3), run:'all' -- f2+f3 supplied headers={"x-f2":"yes","x-f3":"yes"} expected=200 got=200 PASS Control B (f1 OR f2) AND f3 -- f2+f3 supplied headers={"x-f2":"yes","x-f3":"yes"} expected=200 got=200 ==> ALL EXPECTATIONS MET
The controls matter as much as the failures. A patch that turns a bypass into a blanket 401 isn’t a fix, it’s an outage — so the PoC also asserts that requests which genuinely satisfy each expression still get through. They do.
The fix
The maintainers’ patch (5.1.1, by @UlisesGascon, reviewed by @mcollina) does two things: it adds a new latch, and it short-circuits where short-circuiting was always correct.
The new latch is a dedicated field for the nested group’s verdict, so the group’s failure survives its own remaining iterations:
} else { // sub-array relation is AND
if (err) {
// Under run:'all' keep running every function (for side effects),
// but latch the failure so a later success can't mask it.
if (that.options.run !== 'all') return callback(err)
that.subArrayError = err
}
that.processAuthArray(funcs, callback, that.subArrayError || err)
}
Note what it preserves: under run: 'all' every function still executes. The side effects people rely on keep happening. What changes is that running is no longer the same thing as voting — subArrayError || err means a recorded failure outranks anything that comes after it.
And Case 2 gets the obvious answer — a failing OR group inside an AND chain ends the chain:
if (that.options.relation === 'and') { // sub-array relation is OR
if (err && that.options.run !== 'all') {
that.currentError = err
return that.completeAuth() // <- stop; nothing later may overwrite this
}
if (err && that.options.run === 'all' && !that.skipFurtherErrors) {
// Latch the failure so a later passing element cannot clear it.
that.skipFurtherErrors = true
that.currentError = err
}
that.nextAuth(err)
}
There’s a third hunk, and it’s the one I’d have missed: under relation: 'or' with run: 'all', a nested AND group that passes could be masked by a later top-level alternative that fails — the same last-writer-wins defect pointing the other way. That direction fails closed, so it’s a bug and not a vulnerability, but it’s the same mistake, and the patch latches the success too rather than fixing only the half that bled. Fixing one direction of a symmetric bug is how you end up back in the advisory queue six months later.
Takeaways
- An evaluator’s verdict must be write-once, not last-write. Fold results into a decision (
err = err || next), or latch the decision and refuse later writes. Plain assignment inside a callback is how AND silently becomes “whatever finished last.” - Short-circuiting is correctness, not optimisation.
A AND Bstops at a falseAbecause continuing has no meaning. When you skip the short-circuit for an unrelated reason — “run everything for side effects” — you have to add back, by hand, the guarantee it was providing for free. - Separate “run it” from “count it.” The legitimate need was executing every auth function for its side effects. The bug was letting execution imply a vote. Two different questions; give them two different variables — which is exactly what
subArrayErroris. - Composition features deserve a truth table. Two relations × nested/flat ×
run: 'all'or not is eight configurations. The vulnerable paths were the ones where nobody had written down the expected answer. If your library composes security decisions, enumerate the whole table in tests — including the boring rows. - Test the false cases hardest. Auth tests overwhelmingly check “valid request gets in.” Both of these bypasses are invisible to that test and obvious to its opposite: this request should be rejected — is it?
- Fix both directions of a symmetric bug. The fail-open half is the emergency; the fail-closed half is the same defect waiting for a different config.
There was nothing exotic here — no parser, no encoding, no clever payload. Just a variable that more than one thing was allowed to write, in the place where the answer to “is this person allowed?” was kept.
Published September 16, 2026 as GHSA-7h52-2rwr-m76r / CVE-2026-92087 — High, CVSS 8.1 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N), CWE-285 (Improper Authorization) and CWE-697 (Incorrect Comparison). Reported privately through the Fastify GitHub Security Advisory process; credited as reporter, with the remediation built by @UlisesGascon and reviewed by @mcollina.
Affects @fastify/auth >= 5.0.0, < 5.1.1; fixed in 5.1.1. If you nest arrays in a fastify.auth([...]) call — or pass run: 'all' — upgrade. Until you can, flatten the expression: a single-level and or or chain is not affected.