← All posts
· 5 min read ·
AWSSecurityAPI GatewayCloudFrontDevSecOpsLambda

API Gateway Throttling, Brute Force Protection, and the Limits of Edge Security

Rate limiting at the API Gateway level stops obvious brute force attacks, but it's not a substitute for authentication hardening. Here's a layered approach using APIGW throttles, CloudFront functions, Lambda authorizers, and Cost Anomaly Detection.

Layered security architecture diagram for API Gateway

A single-IP brute force attack against an API key is a routine event. The first sign is often a CloudWatch alarm: 300 requests in an hour, all returning 401. The attacker found your API Gateway endpoint directly, bypassing CloudFront, and is trying to guess keys at a rate that costs you in Lambda invocations. Here’s how to respond - and how to be ready before it happens.

The Attack Pattern

Direct API Gateway access is the first thing to close. If your API Gateway URL is publicly discoverable (it is - the format is https://{api-id}.execute-api.{region}.amazonaws.com/{stage}), anyone who finds it can bypass CloudFront entirely. That means:

  • Your IP blocking rules (CloudFront Functions) are bypassed
  • Your geographic restrictions (CloudFront) are bypassed
  • Every request invokes Lambda, costing you money

The fix: make CloudFront the only valid entry point.

Layer 1: Origin Secret (X-Origin-Verify)

The cheapest effective control is a shared secret between CloudFront and API Gateway. CloudFront adds the header on every request; Lambda checks for it before doing anything else.

Set the secret in Secrets Manager:

aws secretsmanager create-secret \
    --name my-api/origin-verify \
    --secret-string "$(openssl rand -hex 32)"

CloudFront adds the header (Terraform):

resource "aws_cloudfront_origin_request_policy" "api" {
    name = "api-origin-request"

    headers_config {
        header_behavior = "whitelist"
        headers {
            items = ["X-Origin-Verify"]
        }
    }

    cookies_config  { cookie_behavior  = "none" }
    query_strings_config { query_string_behavior = "none" }
}

# CloudFront Function adds the secret header
resource "aws_cloudfront_function" "origin_verify" {
    name    = "add-origin-verify"
    runtime = "cloudfront-js-2.0"
    publish = true
    code    = <<-EOF
        function handler(event) {
            event.request.headers["x-origin-verify"] = {
                value: "${var.origin_verify_secret}"
            };
            return event.request;
        }
    EOF
}

Lambda authorizer validates it (API Gateway HTTP API v2):

def handler(event, context):
    headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
    provided = headers.get("x-origin-verify", "")
    expected = _get_secret(os.environ["ORIGIN_VERIFY_SECRET_ARN"])
    return {"isAuthorized": provided == expected}

Attach this as a REQUEST authorizer on all routes with enable_simple_responses = true and a 300-second cache. The authorizer runs once per unique header value per 5 minutes - no performance overhead for legitimate CloudFront traffic.

Direct API Gateway access returns 401 immediately without invoking your main function.

Layer 2: APIGW Throttling

API Gateway throttling limits the total request rate regardless of origin. Set it conservatively for endpoints that don’t need high throughput:

resource "aws_apigatewayv2_stage" "default" {
    api_id      = aws_apigatewayv2_api.main.id
    name        = "$default"
    auto_deploy = true

    default_route_settings {
        throttling_burst_limit = 5   # Max concurrent requests
        throttling_rate_limit  = 2   # Sustained requests per second
    }
}

Burst 5, rate 2 means: at most 5 simultaneous requests, at most 2 per second sustained. For a personal API or low-traffic service, this is more than enough and caps your Lambda invocation rate at ~7,200/hour even in the worst case.

For higher-traffic APIs, set throttling per-route rather than globally:

resource "aws_apigatewayv2_route" "sensitive" {
    # ...
    # Route-level override for sensitive endpoints
    default_route_settings {
        throttling_burst_limit = 2
        throttling_rate_limit  = 1
    }
}

Layer 3: CloudFront Function IP Blocking

For known bad IPs, block at CloudFront before the request reaches API Gateway (and before it incurs Lambda cost):

// cloudfront-js-2.0  -  use plain object, not Set
var BLOCKED = {
    "93.123.109.209": "brute-force probe 2026-04-02",
    "185.220.101.0": "tor-exit-node",
};

function handler(event) {
    if (BLOCKED[event.viewer.ip]) {
        return {
            statusCode: 403,
            statusDescription: "Forbidden"
        };
    }
    return event.request;
}

Note: Set is not available in cloudfront-js-2.0. Use a plain object with BLOCKED[ip] for O(1) lookup.

Maintain this list in Terraform. Keep entries with context (the date and reason) so you can decide later whether to remove stale blocks.

Layer 4: CloudWatch Alarms

Detect attacks before they become expensive:

# Alert on 4xx spike  -  early brute force indicator
resource "aws_cloudwatch_metric_alarm" "api_4xx_spike" {
    alarm_name          = "api-4xx-spike"
    metric_name         = "4xx"
    namespace           = "AWS/ApiGateway"
    statistic           = "Sum"
    period              = 300  # 5 minutes
    evaluation_periods  = 1
    threshold           = 50
    comparison_operator = "GreaterThanOrEqualToThreshold"
    alarm_actions       = [aws_sns_topic.security_alerts.arn]

    dimensions = {
        ApiId = aws_apigatewayv2_api.main.id
    }
}

# Alert on Lambda invocation spike  -  cost indicator
resource "aws_cloudwatch_metric_alarm" "lambda_invocations" {
    alarm_name          = "lambda-invocation-spike"
    metric_name         = "Invocations"
    namespace           = "AWS/Lambda"
    statistic           = "Sum"
    period              = 3600
    evaluation_periods  = 1
    threshold           = 500
    comparison_operator = "GreaterThanOrEqualToThreshold"
    alarm_actions       = [aws_sns_topic.security_alerts.arn]

    dimensions = {
        FunctionName = aws_lambda_function.api.function_name
    }
}

These alarms give you a notification within 5 minutes of an unusual 4xx pattern and within an hour of unexpected Lambda volume.

Reading the Logs

When an alarm fires, pull the logs to understand what happened:

# Last hour of 4xx responses with paths and IPs
aws logs filter-log-events \
    --log-group-name /aws/apigateway/my-api \
    --start-time $(date -u -v-1H +%s)000 \
    --filter-pattern '"status":4' \
    --query 'events[*].message' \
    --output text | jq -r '. | @json' | jq -r '.ip + " " + .path'

# Lambda invocation errors
aws logs filter-log-events \
    --log-group-name /aws/lambda/my-function \
    --start-time $(date -u -v-1H +%s)000 \
    --filter-pattern "ERROR"

The combination of 4xx alarm + Lambda invocation alarm + Secrets Manager GetSecretValue call frequency tells you whether the attack is:

  • Direct APIGW access (high 4xx, low Lambda invocations if authorizer is blocking)
  • Brute force through CloudFront (high 4xx, high Lambda invocations)
  • Credential stuffing against a specific route (moderate 4xx, concentrated on one path)

Each has a different response. Direct access → verify origin verify is working. Brute force through CloudFront → block the IP, tighten throttle. Credential stuffing → investigate what was accessed before the 4xx started.

The Cost Ceiling

Even without WAF, this layered approach creates a cost ceiling. The worst case:

  • Authorizer blocks direct APIGW access: ~0 Lambda main handler invocations
  • APIGW throttle at rate 2: max 7,200 authorizer invocations/hour
  • Authorizer is cheap: ~1ms per invocation, ~$0.002/hour at max throttle
  • CloudFront IP block: known bad IPs never reach APIGW at all

WAF adds $5-15/month for managed rules. For a personal project, the above controls provide adequate protection at near-zero marginal cost.

← All posts