2025, Dec 11 01:00

Customize AWS WAF 403 Responses on CloudFront: Use Lambda@Edge at Origin Request, Not Viewer Response

Why AWS WAF 403 blocks don't trigger Viewer Response in CloudFront, and how to serve a custom 403 page via Lambda@Edge with Origin Request and error handling.

When you combine AWS WAF with CloudFront and try to customize the user-facing response using Lambda@Edge, it’s tempting to hook into the Viewer Response event and rewrite the body on a 403. In practice, that won’t fire the way you expect. If you’re seeing the default 403 from WAF and your Lambda@Edge function never runs, you’re bumping into how CloudFront events are triggered for error responses.

Problem setup

The goal is straightforward: when the request is blocked, show a small HTML page with the client’s IP. The function is attached to Viewer Response and checks the response status to render custom HTML.

def edge_entry(evt, ctx):
    ip_addr = evt['Records'][0]['cf']['request']['clientIp']
    resp_status = evt['Records'][0]['cf']['response']['status']
    resp_headers = evt['Records'][0]['cf']['response']['headers']
    passthrough = evt['Records'][0]['cf']['response']
    html_doc = f"""
    <!DOCTYPE html>
    <html>
    <head><title>Access Denied</title></head>
    <body>
        <h1>Access Denied</h1>
        <p>Your IP address is: {ip_addr}</p>
    </body>
    </html>
    """
    if resp_status == '403':
        crafted = {
            'status': '403',
            'statusDescription': 'Forbidden',
            'headers': resp_headers,
            'body': html_doc,
            'bodyEncoding': 'text',
        }
        return crafted
    else:
        return passthrough

Why it doesn’t work

The Viewer Response event doesn’t get triggered on > 4xx response codes. That’s why your function isn’t running when WAF blocks a request. On top of that, if you haven’t configured a custom response, the platform will return the default block page. As stated:

WAF will return the default block response to the client if neither WAF nor the protected resource (CloudFront in this case) is configured with a custom response.

Changing the type of the status check (string vs int) won’t help; this isn’t a comparison bug, it’s about the event never firing for the block response.

What to do instead

If you need a dynamic page for blocked requests, wire the solution around CloudFront’s error handling and the Origin Request event. Configure CloudFront with a custom response for HTTP 403, create a behavior for that path with caching disabled, and attach your Lambda@Edge function to the Origin Request event for that behavior. In this flow, the function won’t receive an origin response to inspect, so it should always return the dynamic 403 payload itself.

Fixed approach: always render the 403 at Origin Request

The function below constructs the HTML and returns a 403 every time it’s invoked on that behavior. The IP is still read from the request, so you can personalize the block page without depending on an origin response.

def edge_origin_request(ev, cx):
    requester_ip = ev['Records'][0]['cf']['request']['clientIp']
    content = f"""
    <!DOCTYPE html>
    <html>
    <head><title>Access Denied</title></head>
    <body>
        <h1>Access Denied</h1>
        <p>Your IP address is: {requester_ip}</p>
    </body>
    </html>
    """
    return {
        'status': '403',
        'statusDescription': 'Forbidden',
        'headers': {},
        'body': content,
        'bodyEncoding': 'text',
    }

Why this matters

Understanding which CloudFront events are emitted for error scenarios saves time and avoids blind debugging. Viewer Response is not the right place to modify bodies for blocked requests when those responses never reach that stage. Leaning on CloudFront’s custom error response and shifting logic to Origin Request puts you back in control, while still letting AWS WAF enforce the block.

Takeaways

If a WAF block returns a 403 before Viewer Response triggers, the function won’t run, even if it’s correctly attached. Configure CloudFront to use a custom 403 response, route it through a behavior with caching disabled, and handle the rendering in an Origin Request Lambda@Edge that always returns the 403 page. This keeps the enforcement at the edge and gives you predictable, dynamic output for blocked traffic.