skip to content

A REST API in Amazon API Gateway is protected by a Lambda authorizer. What exactly must that function return for the request to be allowed, and how does the backend integration learn who the caller was?

level: middleimportance: must knowfreq 70%

answer

  1. principalId plus a policy document
  2. action is always execute-api:Invoke
  3. Deny gives 403, 'Unauthorized' gives 401
  4. context travels to the integration
  5. flat values only, no nested objects

basics

~20 s

A REST API Lambda authorizer must return a principalId, plus a policyDocument — an IAM policy allowing execute-api:Invoke on the requested method ARN. Anything in the optional context map is passed through to the integration as caller identity.

solid answer

~50 s

For a REST API, the authorizer response is an object with `principalId` (a caller identifier of your choosing), a `policyDocument` — a real IAM policy whose statement has `Action: "execute-api:Invoke"`, an `Effect` of Allow or Deny, and a `Resource` matching the method ARN — and an optional `context` map of string, number or boolean values. API Gateway evaluates that policy: an Allow lets the request through, a Deny returns `403`, and throwing an error whose message is `Unauthorized` returns `401`. The `context` entries are how identity reaches the backend: they appear as `$context.authorizer.<key>` in mapping templates, or under `event.requestContext.authorizer` in a Lambda proxy integration. The authorizer itself is either TOKEN type, which receives a single header value as `event.authorizationToken`, or REQUEST type, which receives headers, query strings and stage variables. HTTP APIs can instead use a simple response of `{ "isAuthorized": true, "context": {...} }`.

code

javascript · 20 lines
javascript
const verify = (t) => (t === 'Bearer good' ? { sub: 'user-42', tenant: 't-9' } : null);

exports.handler = async (event) => {
  const claims = verify(event.authorizationToken); // TOKEN authorizer identity source
  if (!claims) {
    throw new Error('Unauthorized'); // API Gateway turns this into 401
  }
  return {
    principalId: claims.sub,
    policyDocument: {
      Version: '2012-10-17',
      Statement: [{
        Action: 'execute-api:Invoke',
        Effect: 'Allow',
        Resource: event.methodArn,
      }],
    },
    context: { tenantId: claims.tenant, tier: 'gold' }, // strings/numbers/booleans only
  };
};

go deeper

for a junior

Recall the three fields — principalId, policyDocument, optional context — and that the policy's action is execute-api:Invoke. Be able to say the authorizer runs before the integration.

for a middle

Explain TOKEN versus REQUEST identity sources, the Allow/Deny to 403 and Unauthorized to 401 mapping, and exactly how context surfaces as $context.authorizer.* or event.requestContext.authorizer.

for a senior

Show the security reasoning: the integration must trust only gateway-injected context, never client headers. Discuss the added invocation on the hot path and why the Resource you return interacts with result caching.

for a principal

Own the platform question — whether one shared authorizer serves many APIs, what contract you freeze in context so backends can depend on it, and how you version that contract without breaking every consumer at once.

## The two authorizer shapes A Lambda authorizer is a function you own that API Gateway invokes *before* the integration, to decide whether the request proceeds. On a REST API it comes in two flavours, chosen when you create the authorizer: - **TOKEN** — the identity source is a single header, conventionally `Authorization`. Its raw value arrives as `event.authorizationToken`, and the method ARN being called arrives as `event.methodArn`. You can also attach a regular expression that API Gateway applies to the header before invoking your function, so obviously malformed values are rejected without an invocation. - **REQUEST** — the function receives the whole request context: `event.headers`, `event.queryStringParameters`, `event.pathParameters`, `event.stageVariables` and `event.requestContext`. Use it when the decision depends on more than one header, on the source IP, or on the path. ## The response contract Whatever the flavour, a REST API authorizer must return this shape: ```json { "principalId": "user-42", "policyDocument": { "Version": "2012-10-17", "Statement": [{ "Action": "execute-api:Invoke", "Effect": "Allow", "Resource": "arn:aws:execute-api:us-east-1:111122223333:a1b2c3d4e5/prod/GET/orders" }] }, "context": { "tenantId": "t-9", "tier": "gold" } } ``` Three parts matter. **`principalId`** is a string you choose to name the caller — a user id, a client id, a subject claim. API Gateway logs it and exposes it as `$context.authorizer.principalId`. It has no semantics beyond that. **`policyDocument`** is a genuine IAM policy, not a boolean dressed up as one. API Gateway evaluates it against the ARN of the method actually being invoked. The action is always `execute-api:Invoke`. The resource is an execute-api ARN of the form `arn:aws:execute-api:<region>:<account>:<api-id>/<stage>/<HTTP-METHOD>/<resource-path>`, and wildcards are allowed in the trailing segments. If the evaluation yields Allow, the integration runs; anything else is a rejection. **`context`** is an optional flat map. Its values must be strings, numbers or booleans — nested objects and arrays are not supported and are dropped or stringified rather than reaching your backend intact. If you need structure, serialize it to a JSON string yourself. ## How identity reaches the backend This is the half candidates forget. The authorizer decided; now the integration needs to *know* who it is serving, and it must not take the client's word for it. With a **Lambda proxy integration**, the values show up at `event.requestContext.authorizer` — for example `event.requestContext.authorizer.tenantId`. With a **non-proxy integration**, you reference them in a mapping template as `$context.authorizer.tenantId` and `$context.authorizer.principalId`, and pass them into the integration request. For an HTTP-backend integration you typically map them into headers. The security point: because the gateway populates these values from the authorizer's response, a client cannot forge them, whereas a header the client sent is only ever a claim. Trust `$context.authorizer.*`; never trust an inbound `X-Tenant-Id`. ## Status codes - Return an Allow policy → the integration runs. - Return a Deny policy → API Gateway responds `403 Forbidden`. - Throw an error whose message is exactly `Unauthorized` → API Gateway responds `401 Unauthorized`. - Throw any other error, time out, or return a malformed response → `500`. That mapping is worth memorising because it decides what your clients see. A missing token is conventionally a `401`; a valid token without the right permission is a `403`. ## Two more knobs The response may also carry **`usageIdentifierKey`**. When the API's key source is set to `AUTHORIZER` rather than the header, that value is the API key API Gateway uses to select a usage plan — so the authorizer decides the caller's plan and the client never handles a key. On **HTTP APIs** the contract is different and simpler. With payload format version 2.0 and simple responses enabled, the function returns `{ "isAuthorized": true, "context": { ... } }` and no policy at all. You can still opt into the IAM-policy response format on an HTTP API if you need per-route granularity; the simple form is a whole-route yes/no. ## What good answers add Mention that the authorizer is an extra invocation on the request path, that its result is cached to avoid paying for it on every call, and that the policy you return is cached along with everything else — which is why the shape of the `Resource` you return matters more than it first appears.

  • Your authorizer puts an object into `context` and the backend receives `[object Object]`. Why?
    The `context` map supports only string, number and boolean values. Nested objects and arrays are not passed through as structured data — they are dropped or coerced to a string. Serialize the structure to JSON yourself and parse it in the integration, or flatten it into individual scalar keys.
  • A client sends no token at all. How do you make API Gateway return 401 rather than 403?
    Throw an error whose message is exactly `Unauthorized`; API Gateway maps that to `401`. Returning a Deny policy instead produces `403`. For a REQUEST authorizer with caching enabled, a request missing a declared identity source is rejected with `401` before your function is even invoked.
  • Why should the backend read `$context.authorizer.tenantId` instead of a header the client sent?
    Anything the client sends is an unverified claim it can set to any value. Values under `$context.authorizer.*` are injected by API Gateway from the authorizer's response, after the authorizer verified the credential, so the client cannot forge them. Reading a client header for tenant identity is a straightforward horizontal-privilege bug.
  • How does the response contract differ on an HTTP API?
    With payload format 2.0 and simple responses enabled, the function returns `{ "isAuthorized": true, "context": {...} }` — no `principalId` and no policy document. That is a whole-route decision. You can still choose the IAM-policy response format on an HTTP API when you need one authorizer to allow some routes and deny others.

saying these in an interview costs you the question

  • Returns a plain true/false instead of a policy document
  • Uses an action other than execute-api:Invoke in the statement
  • Assumes a Deny policy produces 401 rather than 403
  • Puts nested JSON objects into the context map
  • Reads tenant identity from a client-supplied header instead of the authorizer context

context