Express API key authentication middleware, past the hardcoded array
Every Express API key tutorial stops at an array of strings and a comparison. Here are the three Express-specific traps that break it before you reach the database, and the four things it needs to survive real customers.

Sorower
Co-founder

In this article
- The Express API key authentication middleware everyone ships
- Three Express traps before you reach the database
- 1. Registration order is your security boundary
- 2. Mounting rewrites req.url under you
- 3. Async middleware behaves differently depending on your major version
- The four things that break at customer #2
- Storage: hash the key, and not with bcrypt
- Lookup: you cannot index a loop
- Revocation: a redeploy is not a kill switch
- Identity: return the customer, not true
- What you are actually signing up to maintain
- The same middleware, managed
- Questions you will hit in week one
- Key takeaways
The Express API key authentication middleware in the top search results is about twelve lines long, and it works. I have shipped it. You have shipped it. It reads a header, compares it against an array of strings, calls next(), and goes home.
Then a second customer signs up.
Nothing dramatic happens at that moment. The middleware keeps returning 401s to the wrong keys and 200s to the right ones. What changes is that the array is now a deployment artifact, the keys are now in your logs, and "revoke this customer" is now a pull request. Six weeks later someone asks which customer burned through the rate limit last Tuesday and the honest answer is that nobody can tell, because the middleware only ever returned a boolean.
This post keeps the twelve lines. It is a good twelve lines. Then it covers the three Express-specific ways it breaks before you have even thought about storage, and the four things you have to add before it survives real customers.
The Express API key authentication middleware everyone ships
Here it is, more or less as every tutorial writes it:
// auth.js
const API_KEYS = new Set((process.env.API_KEYS ?? '').split(',').filter(Boolean))
export function apiKeyAuth(req, res, next) {
const key = req.get('X-API-Key')
if (!key || !API_KEYS.has(key)) {
return res.status(401).json({ error: 'Invalid API key' })
}
next()
}
Two things this version already gets right. It uses a Set rather than an array, so the lookup does not get slower as you add customers. And it uses req.get(), which the Express request docs define as a case-insensitive match, so a client sending x-api-key or X-Api-Key is treated the same. Reaching into req.headers['X-API-Key'] instead returns undefined every time, because Node lowercases header names before you ever see them. That one has cost people an afternoon.
$ curl -i -H "X-API-Key: nope" http://localhost:3000/v1/reports
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
{"error":"Invalid API key"}
If you are protecting an internal service with three consumers you control, stop here. This is the right amount of engineering and anything more is a hobby. The rest of this post is for the case where the people holding the keys do not work with you.
Every API key system starts as a string comparison. The ones that survive stop being a string comparison before the second customer, not after the first incident.
Three Express traps before you reach the database
These have nothing to do with cryptography or storage. They are ways the middleware silently fails to run, or runs on the wrong thing, and they are specific to how Express composes a request pipeline.
1. Registration order is your security boundary
Express runs the stack in the order you registered it and stops at the first handler that sends a response. So this leaves a route wide open:
const app = express()
app.get('/v1/reports', reportsHandler) // registered first, never sees auth
app.use(apiKeyAuth) // everything BELOW this is protected
app.get('/v1/exports', exportsHandler)
There is no warning for this. No startup error, no log line. /v1/reports answers with a 200 and full data to a request carrying no key at all, because reportsHandler responds before apiKeyAuth is ever reached. It usually happens during a refactor, when someone moves a route file import to the top of the list to keep it alphabetical.
The durable fix is to stop relying on line order as a security control. Attach auth to the thing it protects:
app.use('/v1', apiKeyAuth, v1Router) // structural, not positional
How do I catch this in CI? Assert it, once, for every route. Express exposes its router stack, but the more honest test is to fire real requests at the app and demand a 401:
import request from 'supertest'
import { app } from '../app.js'
const PROTECTED = ['/v1/reports', '/v1/exports', '/v1/usage']
test.each(PROTECTED)('%s requires a key', async (path) => {
const res = await request(app).get(path)
expect(res.status).toBe(401)
})
Add the path to the array when you add the route. It is a two-line habit that catches the one bug in this post that leaks data rather than merely annoying someone.
2. Mounting rewrites req.url under you
This is the trap that gets people who did everything else right. The Express docs are explicit that req.originalUrl exists precisely because mounting with app.use() rewrites req.url to strip the mount point. Inside middleware mounted at /admin, a request to /admin/new sees:
app.use('/admin', (req, res, next) => {
req.originalUrl // '/admin/new'
req.baseUrl // '/admin'
req.path // '/new'
next()
})
Now consider the skip list that every real auth middleware grows about a week in:
const PUBLIC = new Set(['/health', '/v1/health'])
export function apiKeyAuth(req, res, next) {
if (PUBLIC.has(req.path)) return next()
// ...
}
Mounted at the app root, req.path is /v1/health and the first entry is dead weight. Mounted at /v1, req.path is /health and the second entry is dead weight. Both "work" in the sense that one of the two entries matches, which is exactly why nobody notices that the list means something different depending on where the router got mounted. Move the router six months later and one of two things happens: a route you meant to protect quietly opens, or your load balancer's health check starts taking 401s at 3 AM.
Pick one rule and write it in a comment above the set. If the middleware could ever be mounted on a path, match against req.originalUrl or against req.baseUrl + req.path. If it is always at the app root, match req.path and say so. The bug is not choosing wrong, it is choosing silently.
3. Async middleware behaves differently depending on your major version
The moment your key check touches a database, the middleware becomes async, and Express 4 and Express 5 diverge sharply. The Express error-handling guide states that from Express 5, handlers and middleware returning a Promise call next(value) automatically when they reject. Express 4 does not. It requires you to catch and forward errors yourself.
// On Express 4, a thrown error here does not 500. It hangs.
app.use(async (req, res, next) => {
const record = await db.findKeyByHash(sha256(req.get('X-API-Key')))
if (!record) return res.status(401).json({ error: 'Invalid API key' })
req.customer = record.customer
next()
})
If db.findKeyByHash rejects on Express 4, the promise rejection is unhandled, next() is never called, no response is ever sent, and the request sits there until the client gives up. A hang is meaningfully worse than a 500 to debug, because your error tracker has nothing to report and your dashboard shows latency rather than errors.
Which version are you on? Check rather than assume. As of today the latest tag on npm for express is 5.2.1, with the 4.x line parked behind latest-4 at 4.22.2, so a fresh npm install express gives you 5. A project started in 2023 and never upgraded does not.
$ npm ls express
my-api@1.0.0
└── express@4.21.2
On 4, wrap the body in try/catch and call next(err). On either version, add an error handler that deliberately decides what a database failure means: a 500 lets the request through to nothing, while a 401 tells a paying customer their valid key is invalid. Neither is obviously right, which is the point. We wrote a whole post on choosing fail-open or fail-closed before the incident rather than during it.
If middleware ordering and next() still feel fuzzy, this is a genuinely good 14 minutes on how the pipeline composes:
The four things that break at customer #2
Storage: hash the key, and not with bcrypt
A key in an environment variable is a key in your deploy logs, your CI output, your process list, and every heap dump anyone ever takes. Store a hash, compare hashes, and let the plaintext exist exactly once: in the response that issues it.
Use SHA-256, not bcrypt or argon2. This surprises people who correctly learned to never use a fast hash on passwords, so here is the why: password KDFs are deliberately slow because human passwords have terrible entropy and the threat is offline brute force. A 32-byte random API key has enough entropy that brute force is not the threat model, and you would be paying that deliberate slowness on every single request rather than once per login. The FastAPI version of this argument is in our post on API key auth that survives real customers, and it applies identically in Node.
Lookup: you cannot index a loop
Hashing forces a schema decision, which is good, because it also gives you somewhere to put everything else:
create table api_keys (
id bigserial primary key,
customer_id bigint not null references customers(id),
key_prefix text not null, -- 'rk_live_8f3a', safe to show in a UI
key_hash text not null unique, -- sha256 of the full key
status text not null default 'active',
expires_at timestamptz,
created_at timestamptz not null default now()
);
The unique constraint gives you the index for free, so the hot path is one indexed lookup regardless of customer count. Storing key_prefix separately is what lets your dashboard show rk_live_8f3a… next to a revoke button without storing anything sensitive. A prefix is also what makes leaked keys findable: a distinctive, greppable pattern is what secret scanners match on, which is why so many providers you have integrated with use one.
Revocation: a redeploy is not a kill switch
With keys in an env var, revoking one means editing config and shipping. Whatever your deploy takes, that is how long a leaked key keeps working, and it is also a change you cannot make at 2 AM without waking someone with production access. A status column turns that into an UPDATE.
The subtlety is the cache you will add the moment a database round trip per request annoys you. That cache's TTL is not a performance setting, it is your revocation latency: a 60-second cache means a compromised key keeps working for up to 60 more seconds after you kill it. That is a fine trade to make, as long as you make it on purpose. Rotation has the same shape and we covered it in rotating API keys without downtime.
Identity: return the customer, not true
The single highest-leverage change in this whole post is that the middleware should stop answering "is this key valid" and start answering "who is this":
req.customer = { id: record.customer_id, keyId: record.id, plan: record.plan }
next()
Everything you will be asked for next needs the answer to that question and none of it needs a boolean. Per-customer quotas need it. Usage records need it. Audit logs need it. So does the support ticket that starts "one of your customers says the API is slow," which is unanswerable if all you logged was that the key was fine.
What you are actually signing up to maintain
Here is the honest comparison, including where the managed option falls short.
| Approach | Setup | Revocation | Quotas and rate limits | Usage analytics | Cost | Where it falls short |
|---|---|---|---|---|---|---|
| Env var array | Ten minutes | Edit config, redeploy | None | None | Free | No identity, no revocation without a deploy, plaintext keys in logs |
| Your own table | A day to write, then ongoing | One UPDATE, plus cache TTL |
You build and operate them | You build the pipeline and storage | Engineering time, plus the database and analytics store | Quota counters and usage rollups are their own project; the two-day version reliably ships in six weeks |
| Managed key layer | One middleware | API call, propagates without a deploy | Built in, per consumer | Built in, per key and endpoint | ReqKey: free for 100,000 requests/month, then $20/month for 2,000,000 and $25 per additional million | A network hop on every request; in both mode one API call bills as two requests; per-region limit windows |
That last cell is worth expanding rather than burying, because it is the arithmetic you would work out for yourself on day three. ReqKey's pricing counts a request as one key validation or one logged API call. The middleware below runs in mode: "both" by default, which does one of each per incoming request. So 100,000 free requests covers roughly 50,000 of your API calls with analytics on, or the full 100,000 with mode: "validate". Nobody enjoys finding that out from an invoice.
The other honest limitation: validation is served from more than one region, and each region enforces its own rate-limit window. For a normal client that lands in one region this is invisible. A client deliberately spraying requests across regions can reach up to the configured limit in each of them. That is fine for fairness and imperfect for abuse prevention, and you should know which of those you are buying. ReqKey is not the only managed option here either. Unkey and Zuplo both sell into this space, and if all you need is key issuance without metering, the comparison is genuinely closer than we would like.
The same middleware, managed
If you would rather not build the four things above, the shape does not change. It is still one app.use.
npm install reqkey
Node 20 or newer. The package ships ESM and CommonJS builds with TypeScript declarations, and Express shares the standard Connect adapter, so it works on both Express 4 and 5.
import express from 'express'
import { reqkey } from 'reqkey/express'
import type { ReqKeyExpressRequest } from 'reqkey/express'
const app = express()
const v1Router = express.Router()
v1Router.get('/reports', (req, res) => {
const decision = (req as ReqKeyExpressRequest).reqkey
res.json({ reports: [], creditsRemaining: decision?.creditsRemaining })
})
app.use(
'/v1',
reqkey({
projectKey: process.env.REQKEY_PROJECT_KEY,
apiId: 'api_reports',
keyName: 'X-API-Key',
// Full request paths, including the /v1 mount point. See below.
excludePaths: ['/v1/health', '/v1/docs/*'],
// A cheap read costs 1; a bulk export costs 25.
credits: ({ method, path }) =>
method === 'POST' && path.startsWith('/v1/exports') ? 25 : 1,
failureMode: 'closed',
}),
v1Router,
)
app.listen(3000)
Look closely at excludePaths. Those are full request paths including the /v1 mount point, which is the opposite of what trap #2 would lead you to expect. The adapter normalizes every path from req.originalUrl rather than the rewritten req.url, so what it matches on does not shift when you move the router.
I checked this rather than assuming it, because assuming it is how you get a 401 on a health check. Mounted at /v1, an excludePaths of ['/health'] silently excludes nothing and your load balancer's poll gets validated on every hit; ['/v1/health'] does what you meant. The same full path is what the credits resolver receives, which is why the export check above tests /v1/exports. Two different correct answers for two different layers, so read whichever one you are configuring rather than pattern-matching from the other.
Denials come back as a JSON body of {"error": "<slug>", "message": "<human string>"} with these statuses:
| Situation | Status | Error slug |
|---|---|---|
| No key on the request | 401 | missing_api_key |
| Key unknown or inactive | 401 | invalid_api_key |
| Key not allowed for this API | 403 | access_denied |
| Credit pool exhausted | 402 | insufficient_credits |
| Over the consumer rate limit | 429 with Retry-After | rate_limited |
| Service unreachable, failing closed | 503 | reqkey_unavailable |
The 402 and 429 split is the part people underestimate. They are not the same failure: 402 means out of quota and retrying will not help until a refill, while 429 means too fast and backing off genuinely works. Collapsing both into "rate limited" is how you end up with clients that retry forever against a wall. Our post on getting 429 responses right covers what each one tells a client to do.
On success the middleware attaches the decision to req.reqkey and sets X-ReqKey-Request-ID, X-ReqKey-Credits-Limit, X-ReqKey-Credits-Remaining and X-ReqKey-Validation-Time-Ms. That last one carries a real measured number for that request, which is the only latency figure worth trusting: measure it from your own deployment rather than believing anyone's marketing page, including ours. Full options are in the Node SDK docs.
Questions you will hit in week one
Do CORS preflights get charged or rejected? No. skipMethods defaults to ["OPTIONS"], so preflight passes through unvalidated and unbilled. Worth knowing because a browser-facing API that charged for preflights would roughly double its own bill, and because if you override skipMethods you have just opted back in.
Why does my validation not retry on a timeout? Because it deducts credits. An automatic retry of a call that spends a customer's money is a decision the SDK deliberately refuses to make for you. If you want retries, you write them, and you own the double-charge question that comes with them. We went through that failure mode in detail in how credit systems bill before they know the request worked.
What if the key is in a query parameter? Supported through keyLocation: 'query', and you should avoid it anyway. URLs land in browser history, proxy logs, referrer headers, and your own access logs, which turns every one of those into a credential store.
Does any of this replace the ordering rule? No. Managed or hand-rolled, a route registered above your auth middleware is unprotected. Keep the supertest loop.
Key takeaways
- Attach auth structurally, not positionally.
app.use('/v1', apiKeyAuth, v1Router)cannot be broken by someone reordering imports, and a test that asserts 401 on every protected path catches the one bug here that actually leaks data. - Decide whether your skip list means
req.pathorreq.originalUrl, and write it down. Mounting strips the prefix, so the same list silently changes meaning when the router moves. This is how health checks start 401ing. - Check your Express major version before writing async middleware. On 5, rejected promises reach your error handler. On 4, they hang the request with nothing in your error tracker.
- Hash keys with SHA-256, not a password KDF. High-entropy random keys are not threatened by brute force, and a KDF makes you pay for that protection on every request instead of once per login.
- Make the middleware return a customer, not a boolean. Quotas, usage records, audit logs and support answers all need identity, and retrofitting it means touching every route you have already written.
If you get through this list and the part you do not want to own is quotas and per-customer usage, that is the honest dividing line for reaching for a managed layer. ReqKey is one app.use and the free tier covers 100,000 requests a month, which is enough to run your real traffic through it for a while and see what your own validation latency actually looks like before deciding anything.



