Why AWS WAF Shows “blocked: 403 – Missing required header” for API Gateway with Lambda Authorizer v2

descricao: “Learn why AWS WAF returns “blocked: 403 – Missing required header” when API Gateway uses Lambda authorizer v2 and how to correctly return the X‑Amzn‑Trace‑Id header.”

# Why does AWS WAF log show “blocked: 403 – Missing required header” for API Gateway when using Lambda authorizer v2

I was in the middle of a production rollout for a customer‑facing API when the CloudWatch logs started spitting out a line that looked like this:

“`

[WAF] blocked: 403 – Missing required header

“`

The request hit an **API Gateway** endpoint that was protected by **AWS WAF**, and the endpoint used a **Lambda authorizer (v2)**. The API was otherwise healthy – the Lambda function behind the authorizer returned a 200, the integration worked, and the client saw a proper response when I called the endpoint directly from the console. Yet WAF kept rejecting the traffic.

In this post I’ll walk through the exact chain of events, why the missing `X‑Amzn‑Trace‑Id` header matters, and how to make the authorizer play nicely with WAF rule conditions. I’ll also share the code snippets I used to reproduce the bug, the step‑by‑step fix, and a handful of edge‑case considerations you might hit in the wild.

—

## What does the WAF “Missing required header” error actually mean?

When you create a **WAF rule** that inspects request headers, you can configure a *header match* condition. For example, you might have a rule that only allows requests that contain a specific `X‑Amzn‑Trace‑Id` value – a common pattern when you want to ensure that traffic has passed through an internal service that injects that header.

If the request reaches the WAF **without** the header you declared as required, WAF evaluates the rule as a *mismatch* and, depending on your action (Block/Count), returns a **403** with the message **”Missing required header”**. The log entry you saw is exactly that: the request arrived at the WAF front‑end, the rule looked for `X‑Amzn‑Trace‑Id`, didn’t find it, and blocked the request.

> **Pro Tip:** Always double‑check the exact header name (case‑sensitive) in the rule definition. A stray space or a different casing will cause the rule to miss the header even if it’s present.

## Why does the header disappear when I use a Lambda authorizer v2?

The root cause is subtle: **Lambda authorizer v2** (the newer “JWT” and “request” authorizers) **re‑writes the request** before it reaches the downstream integration. In the process, it **removes all incoming headers that are not explicitly whitelisted** in the `authResponse` object. If you don’t explicitly pass the `X‑Amzn‑Trace‑Id` header back, WAF never sees it.

In my original implementation I returned a minimal policy document:

“`json

{

“isAuthorized”: true,

“context”: {

“userId”: “12345”

}

}

“`

Notice there is **no `headers` field**. The authorizer therefore stripped everything else, including `X‑Amzn‑Trace‑Id`. The API Gateway then forwarded the request to the integration **without** that header, and WAF’s rule fired.

## How does the authorizer’s response shape the downstream request?

When you use a **v2 request authorizer**, the response shape is defined by the `AuthResponse` interface:

“`typescript

interface AuthResponse {

isAuthorized: boolean;

context?: { [key: string]: string };

/** Optional – list of headers to forward */

headers?: { [headerName: string]: string };

/** Optional – list of query string parameters to forward */

queryStringParameters?: { [param: string]: string };

}

“`

– `isAuthorized` tells API Gateway whether to allow the request.

– `context` becomes part of the `$context.authorizer` map for downstream Lambda functions.

– **`headers` is the crucial piece** – any header you list here will be added to the request **after** the authorizer finishes.

If you omit `headers`, API Gateway **drops** every incoming header that wasn’t part of the authorizer’s own response. That’s why the `X‑Amzn‑Trace‑Id` vanished.

## Reproducing the issue locally

I like to keep a minimal reproducible repo. Below is the exact setup I used on my laptop (AWS CLI v2, Node.js 20, CDK v2.120). The steps are intentionally verbose so you can follow along.

### 1. Create a simple API with a request authorizer

“`bash

mkdir waf-authorizer-demo && cd waf-authorizer-demo

npm init -y

npm install aws-cdk-lib constructs aws-sdk

“`

Create `lib/api-stack.ts`:

“`typescript

import * as cdk from ‘aws-cdk-lib’;

import { Construct } from ‘constructs’;

import * as apigateway from ‘aws-cdk-lib/aws-apigateway’;

import * as lambda from ‘aws-cdk-lib/aws-lambda’;

import * as wafv2 from ‘aws-cdk-lib/aws-wafv2’;

export class ApiStack extends cdk.Stack {

constructor(scope: Construct, id: string, props?: cdk.StackProps) {

super(scope, id, props);

const authorizerFn = new lambda.Function(this, ‘AuthorizerFn’, {

runtime: lambda.Runtime.NODEJS_20_X,

handler: ‘authorizer.handler’,

code: lambda.Code.fromInline(`

exports.handler = async (event) => {

// Just echo back isAuthorized = true

return {

isAuthorized: true,

context: { userId: ‘demo’ }

};

};

`),

});

const api = new apigateway.RestApi(this, ‘DemoApi’, {

restApiName: ‘WAF Authorizer Demo’,

});

const authorizer = new apigateway.RequestAuthorizer(this, ‘ReqAuth’, {

handler: authorizerFn,

identitySources: [apigateway.IdentitySource.header(‘Authorization’)],

});

const hello = api.root.addResource(‘hello’);

hello.addMethod(‘GET’, new apigateway.MockIntegration({

integrationResponses: [{ statusCode: ‘200’, responseTemplates: { ‘application/json’: ‘{“msg”:”ok”}’ } }],

requestTemplates: { ‘application/json’: ‘{“statusCode”:200}’ },

}), {

authorizer,

methodResponses: [{ statusCode: ‘200’ }],

});

// WAF rule that requires X-Amzn-Trace-Id header

const webAcl = new wafv2.CfnWebACL(this, ‘WebACL’, {

defaultAction: { allow: {} },

scope: ‘REGIONAL’,

visibilityConfig: { sampledRequestsEnabled: true, cloudWatchMetricsEnabled: true, metricName: ‘demoAcl’ },

rules: [{

name: ‘RequireTraceId’,

priority: 1,

action: { block: {} },

statement: {

byteMatchStatement: {

fieldToMatch: { singleHeader: { name: ‘x-amzn-trace-id’ } },

positionalConstraint: ‘EXISTS’,

searchString: ”,

textTransformations: [{ priority: 0, type: ‘NONE’ }],

},

},

visibilityConfig: { sampledRequestsEnabled: true, cloudWatchMetricsEnabled: true, metricName: ‘requireTraceId’ },

}],

});

new wafv2.CfnWebACLAssociation(this, ‘AclAssociation’, {

resourceArn: api.deploymentStage.stageArn,

webAclArn: webAcl.attrArn,

});

}

}

“`

Deploy with CDK:

“`bash

cdk deploy

“`

Now call the endpoint with a trace header:

“`bash

curl -H “X-Amzn-Trace-Id: Root=1-abcdef” https://<id>.execute-api.<region>.amazonaws.com/prod/hello

“`

You’ll see a **403** and the WAF log entry `Missing required header`. The authorizer returned no `headers` field, so the trace header was stripped.

## Fixing the problem: return the header from the authorizer

The solution is straightforward: **explicitly forward the header** in the authorizer response. Update the authorizer code to capture the incoming header and echo it back.

### Updated authorizer code (Node.js)

“`javascript

exports.handler = async (event) => {

// event.headers contains the original request headers

const traceId = event.headers[‘X-Amzn-Trace-Id’] || event.headers[‘x-amzn-trace-id’];

// Build the response – note the `headers` property

return {

isAuthorized: true,

context: { userId: ‘demo’ },

// Forward the trace header unchanged

headers: {

‘X-Amzn-Trace-Id’: traceId || ”, // send empty string if missing – WAF will still block

},

};

};

“`

Deploy the updated Lambda (either via CDK replace or `aws lambda update-function-code`). After the new version is live, the same `curl` request now returns **200** and the WAF log no longer shows the missing‑header error.

> **Pro Tip:** Use `event.headers?.[‘x-amzn-trace-id’]?.toString()` and guard against `undefined` to avoid runtime errors when the client omits the header.

## What if I need to forward *multiple* headers?

Sometimes you have a chain of internal services that each add their own correlation IDs (`X-Request-ID`, `X-Forwarded-For`, etc.). The same pattern applies – collect the ones you care about and list them in the `headers` map.

“`javascript

return {

isAuthorized: true,

context: { role: ‘admin’ },

headers: {

‘X-Amzn-Trace-Id’: event.headers[‘x-amzn-trace-id’],

‘X-Request-ID’: event.headers[‘x-request-id’],

‘X-Forwarded-For’: event.headers[‘x-forwarded-for’],

},

};

“`

Be aware of **header size limits** – API Gateway caps total header size at 10 KB. If you forward a large set, you might hit the limit and get a 502 from API Gateway.

## Edge cases and pitfalls

### 1. Case sensitivity

API Gateway normalises header names to lower‑case when they appear in `event.headers`. When you construct the `headers` map in the authorizer response, you can use any case you like, but the **exact case you declare is what downstream services (including WAF) will see**. If your WAF rule expects `X-Amzn-Trace-Id` (capital “A” and “T”), you must emit that exact casing.

### 2. Missing header on the client side

If a client calls the API without the trace header, the authorizer will forward an empty string. WAF will still see the header (because it exists) but its value will be empty. Depending on your rule’s *match type* (`EXISTS` vs `STARTS_WITH`), the request may still be allowed. If you truly need the header to be present *and* non‑empty, change the WAF rule to a `byteMatchStatement` with `positionalConstraint: EXACTLY` and a non‑empty `searchString` pattern.

### 3. Using a *Lambda authorizer (v1)*

The older v1 authorizer behaves differently – it **does not strip headers** unless you explicitly set `identitySource` to a header that you want to consume. If you migrate from v1 to v2, the header‑stripping behaviour will surprise you. The fix is the same: add the `headers` field to the v2 response.

### 4. Caching authorizer responses

API Gateway can cache authorizer results for up to 300 seconds (default 0). If you enable caching, the **header forwarding happens only on the first request** that populates the cache. Subsequent requests will reuse the cached response, which may not contain a fresh `X‑Amzn‑Trace‑Id`. To avoid stale headers, either:

– Disable caching for this authorizer, or

– Include the trace header in the *cache key* by adding it to `identitySources`.

### 5. Multiple WAF WebACLs on the same API

If you have a *regional* WebACL attached at the stage level **and** another at the CloudFront distribution level, both will evaluate the request. Ensure both rules agree on the header requirement, otherwise you’ll get inconsistent blocking.

## Testing the fix end‑to‑end

After deploying the updated authorizer, I added a simple CloudWatch metric filter to verify that the header is present when the request passes WAF:

“`bash

aws logs put-metric-filter \

–log-group-name “API-Gateway-Execution-Logs_<rest-api-id>/prod” \

–filter-name “TraceHeaderPresent” \

–filter-pattern ‘{ $.requestContext.identity.userAgent = “*” && $.requestContext.http.method = “GET” && $.requestContext.http.headers.x-amzn-trace-id = “*” }’ \

–metric-transformations metricName=TraceHeaderPresent,metricNamespace=APIGateway,metricValue=1

“`

When I hit the endpoint with a trace header, the metric increments, confirming that the header survived the authorizer hop and made it through WAF.

## FAQ

### How can I confirm which headers API Gateway forwards after a Lambda authorizer?

Check the **Integration Request** logs in CloudWatch (`$context.authorizer`) and the **Access Logs** for the stage. The `requestHeaders` field will list all headers that reached the downstream integration.

### Does the `headers` field in the authorizer response support multi‑value headers?

No. The v2 authorizer response only supports a simple key‑value map. If you need to forward duplicate header names, you must consolidate them into a single value or redesign the flow.

### What happens if the authorizer returns a header that already exists in the incoming request?

API Gateway overwrites the incoming header with the value you provide in the `headers` map. This is useful for normalising header names.

### Can I use a WAF rule to *require* a header only for certain HTTP methods?

Yes. Use a **Rule Group** with a **Scope Down Statement** that combines a `ByteMatchStatement` for the header and an `HttpMethod` condition (e.g., `GET` or `POST`).

### Is there a way to make WAF ignore missing headers instead of blocking?

Set the rule action to **Count** or **Allow** for the header‑match statement, and use a separate rule that blocks based on other criteria. WAF cannot “ignore” a missing header when the rule’s action is Block.

## Conclusion

The “blocked: 403 – Missing required header” message from AWS WAF is not a mysterious firewall bug – it’s a direct consequence of how **Lambda authorizer v2** sanitises the request before it reaches downstream services. By explicitly forwarding the `X‑Amzn‑Trace‑Id` (or any other required header) in the authorizer’s `headers` map, you satisfy the WAF rule and restore normal traffic flow.

In production, I always treat header forwarding as a first‑class concern when introducing a request authorizer. It prevents surprising WAF blocks, keeps correlation IDs intact, and makes debugging far easier.

> **Pro Tip:** Add a unit test that calls the authorizer with a mock event containing the required header and asserts that the response includes the same header. This catches regressions before they hit prod.

If you’ve run into similar header‑loss issues with other API Gateway features (e.g., custom domain mappings, Lambda‑proxy integrations), the pattern is the same: **inspect the contract**, **explicitly forward what you need**, and **verify with logs**.

—

Stay tuned to SpiritCode for more deep‑dive engineering posts like this.