A request arrives with a valid token, a malformed JSON body and a path the caller has no business touching. Which component should reject it, and what does each rejection cost? In an API Gateway plus Lambda design there are three candidates: the gateway's request validation, the Lambda authorizer and your handler. Each can say "no", but each can only reason about different information. This guide is a design analysis based on AWS documentation, built around four questions:
- What information does the check need? Header presence, body shape, caller identity and the target resource's owner are four different inputs.
- What does a rejection cost? A rejection at the gateway never invokes your code. A rejection in the handler pays for an invocation.
- How widely is the result cached? A cached decision is only correct if it holds for everything it covers.
- Who changes the rule, and how fast must a change take effect? Revoking access is a different problem from tightening a schema.
Each layer can only answer the question it has data for
| Layer | Question it can answer | What it cannot see |
|---|---|---|
| Gateway validation | Is the request shaped like something the backend understands? | Who the caller is, or any stored state |
| Authorizer | Who is the caller, and may they use this API at all? | The target record or tenant data, unless you fetch it |
| Handler | Everything that needs state: ownership, allowed transitions, conflicts | Nothing it cannot look up, but it pays an invocation |
The mistake to avoid is stretching one layer to do another's job. An authorizer that parses bodies is slow, caches badly and may not even receive the body. A handler that re-implements basic shape checks on every route repeats work the gateway can do for free.
Gateway validation is cheaper than handler code, and narrower than it looks
For REST APIs, API Gateway can validate a request before it reaches the integration. On failure it returns a 400 and the backend is never called, which saves an invocation and gives you a log of rejected shapes in CloudWatch. The limits matter more than the feature:
- Parameters get a presence check only. For values in the path, query string and headers, the gateway checks that they exist and are not blank. It does not check type or format.
- Body validation needs a model. Models use JSON Schema draft 4, so features from newer drafts do not apply.
- Validators attach per method. A new route with no validator is unvalidated.
- Validation is keyed on content type. If no model matches the request's content type, validation is not performed. A model defined only for
application/jsonsays nothing about a request sent with another content type, unless you define a$defaultmodel.
The last point is the one to test first. A body check that silently skips for an unexpected content type looks like protection but is not, so the handler still has to parse defensively. A body-only validator is a small CLI call:
aws apigateway create-request-validator --rest-api-id abc123 \
--validate-request-body \
--no-validate-request-parameters \
--name body-only
Creating the validator does nothing by itself. It takes effect only when a method references it and has a model for the content type.
An authorizer's cached policy covers more routes than the request that created it
This is the sharpest edge in the design. When authorizer caching is on, the authorizer must return a policy that applies to all methods across the API. For a TOKEN authorizer, the header named as the token source becomes the cache key. That means a later request with the same token can be served the cached policy, whichever route it targets. If the first call was a GET on one path and the authorizer returned an Allow scoped to exactly that method, a later POST to another path with the same token reuses it and can be denied.
The failure looks random: it depends on which route a caller hit first and whether the cache entry is still alive. There are three documented ways to handle it:
- Return a policy that is valid for every route and move per-route decisions elsewhere.
- Turn caching off. Setting the TTL to zero disables policy caching, which suits small or low-traffic APIs and keeps per-route logic in the authorizer, at the cost of one authorizer invocation per request.
- For a REQUEST authorizer, add
$context.pathand$context.httpMethodas identity sources. The documentation points to these context variables for enforcing method-specific policies, so each route and method gets its own cache entry.
Here is a sketch of the first option for a TOKEN authorizer, based on documented behavior rather than a deployed system:
export const handler = async (event) => {
const claims = await verifyToken(event.authorizationToken); // your validation
if (!claims) throw new Error("Unauthorized");
// methodArn: arn:aws:execute-api:region:acct:apiId/stage/METHOD/path
const [apiArn, stage] = event.methodArn.split("/");
const resource = `${apiArn}/${stage}/*`; // valid for every route the cache can serve
return {
principalId: claims.sub,
policyDocument: {
Version: "2012-10-17",
Statement: [{ Action: "execute-api:Invoke", Effect: "Allow", Resource: resource }]
},
context: { tenantId: String(claims.tenantId) }
};
};
verifyToken is a placeholder for your own logic. The wildcard is deliberate, but it means the authorizer now grants API-wide invoke permission, so it must not be where per-route authorization lives. It only passes identity data forward in context, and per-route and per-resource decisions move to the handler. Also remember that attaching the authorizer to a new method with caching enabled means reviewing the policy for that method.
The cache TTL is your revocation delay
The default TTL is 300 seconds and the maximum is 3600. Identity sources form the cache key, and if a required identity source is missing or empty, API Gateway returns 401 without calling the authorizer. Permission changes do not reach a cached caller until the entry expires, so the TTL is effectively the delay you accept before a revocation takes effect. If a disabled user keeping access for five minutes is unacceptable, the 300-second default is the wrong number. For sensitive operations, do an additional per-request check in the handler against current state instead of relying on the cache.
HTTP APIs change the authorizer contract
Do not copy REST assumptions to HTTP APIs. With payload format 2.0, the function can return either a Boolean or an IAM policy, simple responses must be enabled explicitly, and an IAM policy is expected by default. Format 1.0 is the choice when you need compatibility with REST APIs. Identity sources act as the cache key there too. A Boolean answer is simpler, but if it is cached per key, the same question applies: is that yes or no true for every route behind the authorizer?
Anything that needs state belongs in the handler
Tenant ownership, record-level permissions and state-dependent rules cannot live in the authorizer or the gateway schema without going stale or becoming slow. The authorizer can hand over a verified subject or tenant identifier through its context, and the handler compares it with the resource being touched. That keeps the cached decision coarse and correct and the fine-grained decision fresh. It also puts the sensitive logic in code you can unit test with ordinary tools.
A verification pass before trusting the split
- Send a body with an unexpected content type to each validated route and confirm it is rejected or safely parsed, rather than silently skipped.
- Add a new route and confirm it has a validator attached. A missing one means no validation.
- With caching on, call two different routes with the same token, in both orders, and confirm both succeed or both fail as intended.
- Revoke a test user and measure how long access persists, then compare that with the TTL.
- Confirm the handler rejects a valid token that targets another tenant's resource.
Where to stay cautious
This is documentation-based reasoning, not a measured result, and no authorizer cost or latency figures are claimed here. Console behavior and limits change, so recheck them against your API type, REST or HTTP, before copying anything above. A layered approach works well: gateway validation for cheap shape checks, treated as incomplete until content types and per-route attachment are tested; an authorizer limited to identity and coarse access, with a policy that is safe under caching; and resource and tenant authorization in the handler. If you cannot state your revocation tolerance as a number, set the TTL conservatively until you can.
Comments
Post a Comment