API Key Authentication for Serverless Functions
Why API key authentication belongs at the gateway—before your code runs—instead of scattered across every serverless handler: route-level keys vs bearer tokens, rotation without a redeploy, per-tool keys for AI-agent endpoints, and where authorization still lives.
Authentication is one of those things that ends up either everywhere or nowhere. In a serverless codebase it tends to end up everywhere: a little validateApiKey() call pasted at the top of every handler, copied from the last function, occasionally forgotten on the next one. That “occasionally forgotten” is the entire problem. This post is about moving api key authentication out of your handler code and up to the gateway, where it runs before your function — and about the honest limits of what a route-level key does and does not buy you.
The angle is deliberately narrow: serverless api key auth enforced at the gateway on Inquir Compute. We will look at why auth belongs before your code, how route-level API keys compare to bearer tokens, how key rotation works without a redeploy, how to scope per-tool keys for AI-agent tool endpoints, how to keep secrets off the handler, and — the part people skip — the difference between authentication (who is calling) and authorization (what they may do). There is a realistic protected route and a client call, and a section on the limits, because pretending a route-level key is a full identity system is how you end up with a false sense of security.
Why API key authentication belongs at the gateway, not in your handler
The failure mode of per-function auth is not that the code is hard. It is that the code is opt-in. Every new function needs a developer to remember to add the check. Code review catches some of the misses; production incidents catch the rest. When you have twenty functions and a security review turns up three endpoints missing their auth middleware because someone forgot to copy the pattern, that is not a bug — it is a category of bug that will keep happening.
Gateway-level auth inverts the default. On Inquir Compute, a gateway route declares an authType, and for a newly created route that field defaults to api-key. You have to consciously set none to make an endpoint public. Authentication is a property of the route, not a line someone has to remember to write. The gateway middleware validates the credential before the function is invoked; if the key is missing or invalid, it returns 401 and your handler never runs. No container spin-up for the rejected call, no invocation billing for traffic that never reached your code.
There is also a fail-closed posture worth naming. If the authentication service is not configured, protected routes are rejected rather than silently served to the public — a misconfigured deploy fails shut, not open. That is the right default for anything guarding data.
The practical result is that the most common serverless auth incident — “deployed a function, forgot the middleware, the endpoint is now publicly reachable” — is designed out. You cannot forget to add auth to a route where auth is the default and where turning it off is an explicit, reviewable authType: "none". This is what it means to protect serverless functions at the boundary instead of at the leaf.
Route-level API key auth: X-Api-Key vs bearer token
The gateway supports three per-route auth modes, and it is worth being precise about what each one checks:
none— public route, no credential required. You opt into this explicitly.api-key— the caller must present a valid key in theX-Api-Keyheader. This is the service-to-service credential you will use for most machine callers.bearer— the caller must presentAuthorization: Bearer <token>, which the gateway validates as a platform user session scoped to the same tenant. On success your handler receives auserIdprincipal.
That third point is the one people get wrong, so read it twice. On Inquir Compute, bearer token serverless auth at the gateway validates a platform session token — the kind a signed-in console user or a same-workspace caller holds — not an arbitrary JWT from your own identity provider. It is designed for human/console callers and testing, not as a general-purpose OIDC validator for your end users. If you need to authenticate your application’s users, validate their JWT inside the handler; the gateway’s bearer mode is not a substitute for that. We will come back to this in the authentication-vs-authorization section.
For gateway authentication with keys, the request headers the gateway allows through CORS include both X-Api-Key and Authorization, so browser and server clients can send whichever the route expects. One convenience-with-a-caveat: an api-key route that has no key allowlist will also accept a same-tenant session bearer token as a fallback, purely so the console can test routes. The moment you set allowedApiKeyIds on the route, that session fallback is disabled — the route is locked to exactly the keys you listed, and a user session cannot slip past the allowlist. If a route is meant for machines only, set the allowlist and it stays machines-only.
Protect serverless functions with a real route and client call
Here is an end-to-end shape: two routes, one locked to a specific API key and one public health check; the handler, which contains no auth code at all; and the client call. This is serverless api key auth with nothing hand-rolled.
[
{
"method": "GET",
"path": "/customers/:customerId",
"routeTarget": "lambda",
"functionId": "fn_customer_data",
"authType": "api-key",
"allowedApiKeyIds": ["key_partner_reporting"],
"eventFormat": "simple",
"rateLimit": 120,
"corsEnabled": true,
"enabled": true
},
{
"method": "GET",
"path": "/health",
"routeTarget": "lambda",
"functionId": "fn_health",
"authType": "none",
"enabled": true
}
]
The protected route sets authType: "api-key" and restricts it to one key with allowedApiKeyIds. The handler behind it is boring on purpose:
// api/customer-data.mjs — auth is enforced at the gateway, not here
export async function handler(event) {
// The gateway already validated X-Api-Key before this code ran.
// If we are here at all, the caller presented a valid, allowed key.
const customerId = event.pathParameters?.customerId;
if (!customerId) {
return { statusCode: 400, body: JSON.stringify({ error: 'customerId required' }) };
}
// Authentication (who) is done. Authorization (what) still lives here:
// only return records and fields this key's tier is allowed to see.
const customer = await db.customers.findById(customerId);
if (!customer) {
return { statusCode: 404, body: JSON.stringify({ error: 'not found' }) };
}
return { statusCode: 200, body: JSON.stringify({ customer }) };
}
Notice what is not in that handler: no key parsing, no hash comparison, no “is this header present” guard. The gateway did all of it. The client sends the key in the header:
# The gateway host is your workspace's default API (/gw/{tenant}/…) or a custom domain.
BASE="https://api.example.com/gw/acme"
# 1) Authenticated call — the key travels in the X-Api-Key header
curl -s "$BASE/customers/42" \
-H "X-Api-Key: $INQUIR_API_KEY"
# → 200 { "customer": { ... } }
# 2) Same route, no key — rejected at the gateway, the handler never runs
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/customers/42"
# → 401
The second call is the whole point: it returns 401 without ever invoking fn_customer_data. Rejected traffic costs you nothing and touches no code.
Authentication vs authorization: who is calling vs what they can do
This is the distinction that separates people who have been burned from people who are about to be. Authentication answers who (or what) is calling: is this a valid, allowed credential for this route? That is what the gateway does. On success it hands your handler a principal — an apiKeyId for a key-authenticated call, a userId for a session — so you know which caller you are talking to. Authorization answers what that caller may do: which records, which fields, which operations, which tenant’s data. The gateway does not and cannot answer that for you, because it is your business logic.
Concretely: the shared reporting key in the example above is authenticated to hit GET /customers/:customerId. It should still not be allowed to read a customer belonging to a different account, or to see the fields reserved for a higher tier. Those checks are authorization, and they live in the handler — which is exactly why the handler still receives the principal. Gateway auth removes the credential-checking boilerplate; it does not remove the permission logic, and you should be suspicious of any platform that claims it does.
This is also how you combine the two auth models cleanly. Use api-key at the gateway for service-to-service routes, and for user-facing routes validate a user JWT inside the handler. Different routes can use different auth models, and a single route can layer them: gateway key auth to prove the request came from your front end or partner, plus in-handler JWT validation to prove which user is behind it. Two different questions, two different layers, no confusion about which one owns what.
API key rotation without a redeploy
Because the key lives in gateway configuration rather than in your source, api key rotation is a config operation, not an engineering project. Inquir Compute has a first-class rotate that creates a new key and deactivates the old one in a single step. The keys themselves are 32-byte base64url values, shown exactly once at creation and stored only as a SHA-256 hash — which means no one, including you, can read a key back later. Capture it when you create it and put it in your secrets process immediately; there is no “show me that key again” button, by design.
Rotation, then, is: mint the new key, point the route’s allowedApiKeyIds at it, and the old key stops working the instant it is deactivated — with no function redeploy, because the function never knew the key in the first place.
# After rotating, the route's allowedApiKeyIds points at the new key and the
# old key has been deactivated. No function code changed; nothing was redeployed.
# Old key — now rejected at the gateway
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/customers/42" \
-H "X-Api-Key: $OLD_KEY"
# → 401
# New key — works immediately
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/customers/42" \
-H "X-Api-Key: $NEW_KEY"
# → 200
Because keys are per-workspace and scoped per route, you can rotate one partner’s key without disturbing internal callers who use a different key on a different route. Keys can also be imported and managed from the CLI per the API Keys documentation, so rotation fits into scripted, auditable operations rather than a console click that no one can reconstruct later. If a key is compromised, the response is the same and it is fast: remove the compromised key, add a new one, redistribute through your secrets process. Sessions holding the old key fail immediately.
Per-tool API keys for AI-agent tool endpoints
If you expose serverless functions as tools for an LLM agent, the per-route scoping becomes a containment strategy. Give each tool group its own key and pin it with allowedApiKeyIds. The payoff is blast radius: prompt injection, a header that got logged, a key that leaked into a trace somewhere — whatever the cause, you rotate that tool group’s key and nothing else moves. A single shared key across every tool means one leak forces a rotation that hits every caller simultaneously; per-tool keys make the leak local.
The second half of doing this well is keeping secrets off the handler and off the model path. The API key that authorizes the tool endpoint is checked at the gateway, so it never needs to appear in handler code. The secrets the tool actually needs downstream — database credentials, third-party API keys — live in per-function environment config that is injected only at invoke time, is redacted in logs and traces by key name and by pattern (values that look like token, Bearer, api-key, and similar are scrubbed), and is never handed to the model. The model sees tool inputs and outputs; it does not see the credentials the tool uses to do its work.
One more guardrail matters here. Deploy-scoped keys — the ones your CI uses to ship functions — are explicitly rejected at the gateway with a forbidden response. A key minted for deployment cannot be replayed against a live tool route, so a leaked deploy token in a CI log is not also a skeleton key to your running endpoints. Each function runs in its own isolated container, so an agent calling a Python tool and a Node tool is not sharing a process between them either.
Honest limits: what gateway API keys are and aren’t
None of the above is worth much if you misjudge the edges. So, plainly:
- It is not a full identity provider.
api-keyis a service/machine credential andbeareris a platform session — there is no OAuth/OIDC flow, no social login, no user directory, no consent screens. For your end users’ identity, validate a JWT from your IdP inside the handler. Gateway auth proves the request is allowed to hit the route; it does not model who your users are. - Rate limiting is best-effort, not a quota. The per-route limit is per source IP, per minute, and held in memory per instance rather than in a distributed store; over-limit calls get
429withRetry-After: 60. Treat it as abuse dampening, not as a billing-grade quota or a hard security control. - It is not a secrets manager. Per-function environment config is injected at invoke and encrypted at rest only if an encryption key is configured; there is no versioning or cross-function referencing. API keys themselves are hashed at rest, but treat every plaintext value like the secret it is.
- Execution limits still apply. Putting auth at the gateway does not change runtime behavior: functions default to a 5-second timeout with a 15-minute maximum, and while hot container pools cut cold starts they do not eliminate them. The first call after idle is still cold.
- Machine-only means set the allowlist. Remember that an
api-keyroute with noallowedApiKeyIdswill also accept a same-tenant session token for console testing. If a route must be machines-only, set the allowlist; that both scopes the route to specific keys and closes the session fallback.
Takeaway
The move is simple to state and it removes a whole class of incidents: make authentication a property of the route, not a line in every handler. On Inquir Compute a gateway route defaults to api-key, rejects bad keys with 401 before your code runs or your invocation is billed, and fails closed when misconfigured — so “I forgot the middleware and the endpoint went public” stops being a thing that can happen. Rotate keys as a config operation with no redeploy, scope a key per partner and a key per agent tool to keep blast radius small, and keep your downstream secrets in per-function env config, off the handler and off the model path.
Then respect the edges. Gateway API key auth is authentication, not authorization — the permission logic stays in your handler, which still gets the caller’s principal. It is a service credential, not an identity provider — end-user identity means validating a JWT in the handler. And its rate limiting is best-effort, not a quota. Put the key check at the boundary, keep authorization and secrets where they belong, and the endpoint that used to be one forgotten if statement away from public is simply closed by default.