How to Build JWT Middleware That Fails Closed
Build JWT middleware that fails closed so a missing or invalid token returns 401, and a key lookup error returns 503 instead of letting the request through.
On this page
Introduction
The hardest part of never let bad token through is not writing the code — it is deciding what the code is allowed to reject. A fail-closed middleware setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in fail-closed middleware, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.
Choosing fail-closed middleware for never let bad token through is a trade, not a winner. You give up some flexibility to get a hard boundary, and the exchange pays off when the boundary catches a class of bugs that would otherwise surface at 3 a.m. Document the trade at the call site so the next reader does not undo the guard thinking it is a bug. The reader who does not know why the guard exists is the reader who removes it.
The sections below walk through the concrete decisions: where to put the fail-closed middleware check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.
Extract the token
Extract the token is the first decision because it determines what the rest of the fail-closed middleware pipeline can assume. A wrong choice here turns into never let bad token through under load, and the invariant you thought fail-closed middleware guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Extract the token is the one most teams skip: drive fail-closed middleware to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
async function auth(req, res, next) {
const t = req.headers.authorization?.slice(7);
if (!t) return res.status(401).end();
try { req.user = await verify(t); next(); }
catch (e) { res.status(e instanceof KeyError ? 503 : 401).end(); }
}
The typescript above is the shape to ship for never let bad token through. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.
Verify or reject
The mechanism behind Verify or reject is straightforward in fail-closed middleware, but the consequences ripple. A wrong choice here turns into never let bad token through under load, and the invariant you thought fail-closed middleware guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Verify or reject is the one most teams skip: drive fail-closed middleware to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Handle key errors
Handle key errors is where most fail-closed middleware implementations leak performance. A wrong choice here turns into never let bad token through under load, and the invariant you thought fail-closed middleware guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Handle key errors is the one most teams skip: drive fail-closed middleware to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
test('returns 401 on missing token', () => {
auth({ headers: {} }, res, next);
expect(res.status).toHaveBeenCalledWith(401);
});
Pin the behavior with this test. A fail-closed middleware setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.
Return 401 on invalid
The hard part of Return 401 on invalid is not the happy path — it is what happens when fail-closed middleware fails halfway. A wrong choice here turns into never let bad token through under load, and the invariant you thought fail-closed middleware guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Return 401 on invalid is the one most teams skip: drive fail-closed middleware to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Return 503 on infra error
Return 503 on infra error should be observable without a debugger. A wrong choice here turns into never let bad token through under load, and the invariant you thought fail-closed middleware guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Return 503 on infra error is the one most teams skip: drive fail-closed middleware to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Test fail-closed
Test fail-closed is a trade, not a winner. A wrong choice here turns into never let bad token through under load, and the invariant you thought fail-closed middleware guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.
The test for Test fail-closed is the one most teams skip: drive fail-closed middleware to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.
Common pitfalls
The common pitfalls in never let bad token through all reduce to one mistake: treating fail-closed middleware as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.
- Skipping the fail-closed middleware boundary so never let bad token through enters unvalidated.
- Assuming fail-closed middleware is fast without measuring the tail latency under load.
- Letting never let bad token through fail silently instead of failing loudly with a metric.
- Testing only the happy path so the fail-closed middleware failure surfaces in production.
- Sharing mutable state across requests so one bad input poisons the next.
Conclusion
Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: fail-closed middleware fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.
Self-check: write a test that drives fail-closed middleware to the failing condition and assert the system degrades gracefully. Then never let bad token through is a property you maintain, not a feature you hope for.
Sources: Express middleware, JWT
FAQ
- Why 503 on key error?
- The server cannot safely verify. 503 tells the client to retry, 401 would force reauth unnecessarily.
- Log the rejection?
- Yes, with the reason. 401 spikes can indicate an attack.
Report a problem with this articleOpens a pre-filled issue on GitHub.