DEV Community

Putting Amazon Cognito Behind CloudFront Without Making the Origin the Public Path

CloudFront and WAF protecting a Cognito hosted UI behind a corporate auth domain

Amazon Cognito hosted UI is convenient: you get OAuth 2.0 endpoints, OIDC discovery, managed login pages, token endpoints, logout endpoints, and federation plumbing without running your own identity provider.

But the default shape has an awkward property: users and clients talk directly to the Cognito domain.

For corporate applications, you may want the public entry point to be a company-owned domain on your own CloudFront distribution instead:

https://auth.mycorpdomain.com
Enter fullscreen mode Exit fullscreen mode

instead of:

https://mycorp-auth.auth.us-east-1.amazoncognito.com
Enter fullscreen mode Exit fullscreen mode

The reason is not only cosmetic. During the whole login conversation, users stay on a domain your company owns. The auth flow can be embedded into the corporate site or launched from the application UI without making users feel like they left your environment and landed on an AWS-owned hostname.

It also gives you the CloudFront edge advantages around the login surface without pretending auth traffic is a caching problem. Users connect to a local AWS edge presence, TLS terminates closer to them, time to first byte can improve for edge-handled responses such as redirects and blocks, and AWS can carry traffic across its own network toward the origin. At the same edge, a CloudFront-scope AWS WAF Web ACL can apply DDoS protection, login protection, rate limits, bot controls, IP reputation, managed rule groups, custom rules, and simple route handling such as /login, keeping most abusive traffic from ever reaching the Cognito-hosted UI or the regional WAF associated with the user pool.

This article walks through a practical version of that pattern.

The architecture

The target shape:

Browser / OIDC client
        |
        v
CloudFront distribution: auth.mycorpdomain.com
        |
        | adds X-Edge-Origin-Secret
        v
Cognito hosted UI domain
        |
        v
User pool
Enter fullscreen mode Exit fullscreen mode

Architecture diagram for protecting Cognito behind CloudFront and WAF
There are two WAF layers:

  1. A CloudFront-scope Web ACL attached to the CloudFront distribution.
  2. A regional Web ACL associated with the Cognito user pool that only allows requests carrying a secret header added by CloudFront.

There is also one small edge function:

  • A CloudFront Function on viewer response rewrites Cognito Location headers from the Cognito origin domain back to auth.mycorpdomain.com.

And one static object:

  • A recreated /.well-known/openid-configuration document served from S3 or another static origin, because OIDC clients need discovery metadata that points at the public CloudFront hostname.

Most user-facing login protection should happen at the CloudFront layer: DDoS protection, login-specific protection, rate limits, path controls, IP reputation, bot controls, geographic policies, managed rule groups, custom rules, and any cheap static redirect for /login. This is separate from caching. For auth traffic, cache hit ratio is not the point. The value is local edge presence, fast edge decisions, AWS network acceleration toward the origin, and stopping bad traffic before it reaches the Cognito path. The regional Cognito WAF remains important, but its main job in this pattern is origin-bypass protection. It should reject direct requests that did not come through CloudFront.

Why not only use CloudFront?

CloudFront can add a custom header to origin requests. That is useful, but it does not stop someone from directly calling the Cognito domain unless the origin also enforces the header.

With many AWS origins, you can make the origin trust CloudFront through a native origin access feature or a resource policy. S3 has Origin Access Control. Some services can restrict access with IAM or resource policies, VPC-only paths, security groups, or other service-native controls.

Cognito hosted UI does not give you that kind of origin access control. You cannot attach application code to the hosted UI, and you cannot write a resource policy that says "only this CloudFront distribution may call this Cognito domain."

The practical way to create one-way stickiness from the public CloudFront hostname to Cognito is:

  • CloudFront adds X-Edge-Origin-Secret: <random value> on origin requests sent to Cognito.
  • A regional AWS WAF Web ACL on the Cognito user pool allows requests with that exact header.
  • Direct calls to the Cognito hosted UI domain do not have the header, so the regional WAF blocks them.

Step 1: create the secret once

Use SSM Parameter Store for the shared secret header value. Set and rotate the value through your secret-management process, not in Terraform source.

Create or rotate the value outside Terraform, for example:

aws ssm put-parameter \
  --name /mycorp/auth/origin-secret \
  --type SecureString \
  --value "$(openssl rand -base64 48)" \
  --overwrite
Enter fullscreen mode Exit fullscreen mode

Terraform should only read the current value:

data "aws_ssm_parameter" "edge_origin_secret" {
  name            = "/mycorp/auth/origin-secret"
  with_decryption = true
}
Enter fullscreen mode Exit fullscreen mode

Do not put the real header value, a placeholder value, or a managed aws_ssm_parameter value in the Terraform module. If Terraform owns the value, it is too easy to leak it into source, state workflows, review systems, or logs.

Step 2: configure CloudFront origin headers

Use the Cognito hosted UI domain as a custom origin.

Example origin values:

CloudFront alias: auth.mycorpdomain.com
Cognito origin:  mycorp-auth.auth.us-east-1.amazoncognito.com
Enter fullscreen mode Exit fullscreen mode

Terraform sketch:

resource "aws_cloudfront_distribution" "auth" {
  enabled         = true
  is_ipv6_enabled = true
  aliases         = ["auth.mycorpdomain.com"]

  origin {
    origin_id   = "cognito-hosted-ui"
    domain_name = "mycorp-auth.auth.us-east-1.amazoncognito.com"

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }

    custom_header {
      name  = "X-Edge-Origin-Secret"
      value = data.aws_ssm_parameter.edge_origin_secret.value
    }
  }

  default_cache_behavior {
    target_origin_id       = "cognito-hosted-ui"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
    cached_methods         = ["GET", "HEAD"]

    cache_policy_id          = aws_cloudfront_cache_policy.auth_disabled.id
    origin_request_policy_id = aws_cloudfront_origin_request_policy.auth_all.id

    function_association {
      event_type   = "viewer-response"
      function_arn = aws_cloudfront_function.rewrite_cognito_location.arn
    }
  }

  web_acl_id = aws_wafv2_web_acl.cloudfront_auth.arn

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate.auth.arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Amazon Cognito hosted UI is HTTPS. Keep origin_protocol_policy = "https-only". The http_port argument appears in Terraform because CloudFront custom origins require the field, but this configuration does not send origin traffic to Cognito over HTTP.

For auth endpoints, caching should usually be disabled or extremely conservative. OAuth requests carry query strings, cookies, state, nonce, code challenges, and other values that should not be collapsed into a shared cached object.

Example cache policy:

resource "aws_cloudfront_cache_policy" "auth_disabled" {
  name        = "mycorp-auth-caching-disabled"
  default_ttl = 0
  max_ttl     = 0
  min_ttl     = 0

  parameters_in_cache_key_and_forwarded_to_origin {
    enable_accept_encoding_brotli = false
    enable_accept_encoding_gzip   = false

    cookies_config {
      cookie_behavior = "none"
    }

    headers_config {
      header_behavior = "none"
    }

    query_strings_config {
      query_string_behavior = "none"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And an origin request policy that forwards what Cognito needs:

resource "aws_cloudfront_origin_request_policy" "auth_all" {
  name = "mycorp-auth-forward-all-viewer-values"

  cookies_config {
    cookie_behavior = "all"
  }

  headers_config {
    header_behavior = "allViewer"
  }

  query_strings_config {
    query_string_behavior = "all"
  }
}
Enter fullscreen mode Exit fullscreen mode

Review your own requirements before forwarding every header. This is a simple starting point for hosted UI compatibility, not a universal least-privilege policy.

Step 3: add a CloudFront WAF redirect for /login

Users like clean URLs. OAuth endpoints do not.

You can make:

https://auth.mycorpdomain.com/login
Enter fullscreen mode Exit fullscreen mode

return a redirect to:

https://auth.mycorpdomain.com/oauth2/authorize?client_id=...&response_type=code&scope=openid+email+profile&redirect_uri=...&code_challenge=...&code_challenge_method=S256
Enter fullscreen mode Exit fullscreen mode

If the redirect target is static enough for your use case, AWS WAF custom responses can do this at the CloudFront Web ACL layer. WAF custom responses can use 3xx status codes and a Location header. You can also implement the same redirect in a CloudFront Function, but WAF is cheaper for this case because the target remains static as long as the user pool and app client configuration remain unchanged.

In production, make the redirect target depend on the current AWS state rather than a hand-copied URL. For example, Terraform can read the user pool client and construct the authorize URL from data sources, so each terraform apply checks the actual app client and domain values.

Terraform sketch:

resource "aws_wafv2_web_acl" "cloudfront_auth" {
  name  = "mycorp-auth-cloudfront"
  scope = "CLOUDFRONT"

  default_action {
    allow {}
  }

  rule {
    name     = "redirect-login"
    priority = 10

    action {
      block {
        custom_response {
          response_code = 302

          response_header {
            name  = "Location"
            value = "https://auth.mycorpdomain.com/oauth2/authorize?client_id=CLIENT_ID&response_type=code&scope=openid+email+profile&redirect_uri=https%3A%2F%2Fapp.mycorpdomain.com%2Fcallback"
          }
        }
      }
    }

    statement {
      byte_match_statement {
        field_to_match {
          uri_path {}
        }

        positional_constraint = "EXACTLY"
        search_string         = "/login"

        text_transformation {
          priority = 0
          type     = "NONE"
        }
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "redirect-login"
      sampled_requests_enabled   = true
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = "mycorp-auth-cloudfront"
    sampled_requests_enabled   = true
  }
}
Enter fullscreen mode Exit fullscreen mode

This looks odd because the WAF action is technically block, but the custom response is a 302. That is how AWS WAF custom responses work: the rule terminates evaluation and returns your response.

If your /login redirect needs dynamic PKCE generation, dynamic state, tenant selection, or request-specific logic, use application code, a CloudFront Function, or Lambda@Edge instead. WAF redirects are best for simple static redirects.

Step 4: rewrite Cognito Location headers

Cognito will often produce redirects that point back at the Cognito hosted UI domain:

Location: https://mycorp-auth.auth.us-east-1.amazoncognito.com/oauth2/...
Enter fullscreen mode Exit fullscreen mode

If clients enter through CloudFront, keep them there:

Location: https://auth.mycorpdomain.com/oauth2/...
Enter fullscreen mode Exit fullscreen mode

CloudFront Functions can run on viewer response and modify response headers before the response is returned.

Function:

function handler(event) {
  var response = event.response;
  var headers = response.headers;

  var publicHost = "auth.mycorpdomain.com";
  var cognitoHost = "mycorp-auth.auth.us-east-1.amazoncognito.com";

  if (headers.location && headers.location.value) {
    headers.location.value = headers.location.value
      .replace("https://" + cognitoHost, "https://" + publicHost);
  }

  return response;
}
Enter fullscreen mode Exit fullscreen mode

Terraform:

resource "aws_cloudfront_function" "rewrite_cognito_location" {
  name    = "mycorp-auth-rewrite-cognito-location"
  runtime = "cloudfront-js-2.0"
  comment = "Rewrite Cognito hosted UI redirects to the public auth domain"
  publish = true
  code    = file("rewrite-cognito-location.js")
}
Enter fullscreen mode Exit fullscreen mode

Keep this function intentionally small. It should not parse tokens, make authorization decisions, or become an identity service. Its job is only to keep redirects on the public hostname.

Step 5: protect Cognito with a regional WAF

Now attach a regional Web ACL to the Cognito user pool and allow only requests with the secret header.

The Web ACL should live in the same region as the user pool.

Keep the responsibility split clear: DDoS protection, login protection, rate limits, bot controls, managed rule groups, custom rules, and other broad controls should run on the CloudFront Web ACL. That lets CloudFront and AWS edge infrastructure handle hostile traffic before it reaches the Cognito service path. The regional Cognito WAF is the final origin-bypass control, not the place where you want internet-scale login floods to arrive first.

resource "aws_wafv2_web_acl" "cognito_origin" {
  name  = "mycorp-auth-cognito-origin"
  scope = "REGIONAL"

  default_action {
    block {}
  }

  rule {
    name     = "allow-cloudfront-origin-secret"
    priority = 10

    action {
      allow {}
    }

    statement {
      byte_match_statement {
        field_to_match {
          single_header {
            name = "x-edge-origin-secret"
          }
        }

        positional_constraint = "EXACTLY"
        search_string         = data.aws_ssm_parameter.edge_origin_secret.value

        text_transformation {
          priority = 0
          type     = "NONE"
        }
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "allow-cloudfront-origin-secret"
      sampled_requests_enabled   = true
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = "mycorp-auth-cognito-origin"
    sampled_requests_enabled   = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Associate it with the user pool:

resource "aws_wafv2_web_acl_association" "cognito" {
  resource_arn = aws_cognito_user_pool.this.arn
  web_acl_arn  = aws_wafv2_web_acl.cognito_origin.arn
}
Enter fullscreen mode Exit fullscreen mode

Test both paths:

curl -I https://auth.mycorpdomain.com/oauth2/authorize
Enter fullscreen mode Exit fullscreen mode

should reach Cognito through CloudFront, while:

curl -I https://mycorp-auth.auth.us-east-1.amazoncognito.com/oauth2/authorize
Enter fullscreen mode Exit fullscreen mode

should be blocked by the regional Web ACL because the secret header is missing.

Do not use a guessable value. The header is not a replacement for OAuth security. It is an origin-bypass control.

Step 6: recreate openid-configuration

OIDC clients discover provider metadata from:

/.well-known/openid-configuration
Enter fullscreen mode Exit fullscreen mode

For Cognito user pools, the AWS-owned discovery document normally lives under the issuer host:

https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE/.well-known/openid-configuration
Enter fullscreen mode Exit fullscreen mode

But if clients are configured to use:

https://auth.mycorpdomain.com
Enter fullscreen mode Exit fullscreen mode

then discovery must return endpoints that send clients to auth.mycorpdomain.com, not the raw Cognito hosted UI domain.

This is not the same as hiding every Cognito identifier. OIDC clients need a real issuer and signing keys. The discovery document, jwks_uri, and ID token iss claim can still expose the Cognito issuer host, region, and user pool ID. That is normal for Cognito token validation.

The goal is narrower and more practical: keep the Cognito hosted UI domain out of the browser routing path, keep OAuth endpoints on the company-owned CloudFront hostname, and block direct use of the hosted UI origin with the regional WAF.

One practical approach is:

  1. Fetch the original Cognito discovery document.
  2. Create a modified copy.
  3. Serve it from S3 behind the same CloudFront distribution at /.well-known/openid-configuration.

Example original shape:

{
  "issuer": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE",
  "authorization_endpoint": "https://mycorp-auth.auth.us-east-1.amazoncognito.com/oauth2/authorize",
  "token_endpoint": "https://mycorp-auth.auth.us-east-1.amazoncognito.com/oauth2/token",
  "userinfo_endpoint": "https://mycorp-auth.auth.us-east-1.amazoncognito.com/oauth2/userInfo",
  "end_session_endpoint": "https://mycorp-auth.auth.us-east-1.amazoncognito.com/logout",
  "jwks_uri": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE/.well-known/jwks.json",
  "response_types_supported": ["code", "token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "email", "phone", "profile"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
  "claims_supported": ["sub", "email", "email_verified", "phone_number", "phone_number_verified"]
}
Enter fullscreen mode Exit fullscreen mode

Modified public copy:

{
  "issuer": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE",
  "authorization_endpoint": "https://auth.mycorpdomain.com/oauth2/authorize",
  "token_endpoint": "https://auth.mycorpdomain.com/oauth2/token",
  "userinfo_endpoint": "https://auth.mycorpdomain.com/oauth2/userInfo",
  "end_session_endpoint": "https://auth.mycorpdomain.com/logout",
  "jwks_uri": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE/.well-known/jwks.json",
  "response_types_supported": ["code", "token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "email", "phone", "profile"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
  "claims_supported": ["sub", "email", "email_verified", "phone_number", "phone_number_verified"]
}
Enter fullscreen mode Exit fullscreen mode

What to replace:

  • authorization_endpoint
  • token_endpoint
  • userinfo_endpoint
  • end_session_endpoint
  • Any other OAuth endpoint value that uses the Cognito hosted UI domain

What not to replace blindly:

  • issuer
  • jwks_uri
  • Supported algorithms
  • Supported claims
  • Supported scopes
  • Supported auth methods
  • Any fields that describe token validation semantics rather than browser routing

The issuer is especially important. Cognito tokens are issued by the Cognito user pool issuer, commonly:

https://cognito-idp.<region>.amazonaws.com/<user_pool_id>
Enter fullscreen mode Exit fullscreen mode

If you change the issuer in discovery but Cognito still signs tokens with the original issuer claim, strict OIDC clients can reject the tokens. Keep issuer behavior aligned with the actual tokens your user pool emits.

So yes, issuer and jwks_uri intentionally reveal that Cognito is the token issuer. They should not reveal the hosted UI origin as the normal authorization, token, userinfo, or logout endpoint after you rewrite those endpoint values, but they do reveal the Cognito user pool identity. Treat this pattern as origin-bypass prevention and corporate-domain continuity, not complete origin secrecy.

Serve the file from S3:

resource "aws_s3_object" "openid_configuration" {
  bucket       = aws_s3_bucket.auth_static.id
  key          = ".well-known/openid-configuration"
  content_type = "application/json"
  source       = "openid-configuration.json"
  etag         = filemd5("openid-configuration.json")
}
Enter fullscreen mode Exit fullscreen mode

Then add a CloudFront behavior for the discovery path:

ordered_cache_behavior {
  path_pattern           = "/.well-known/openid-configuration"
  target_origin_id       = "auth-static"
  viewer_protocol_policy = "redirect-to-https"
  allowed_methods        = ["GET", "HEAD"]
  cached_methods         = ["GET", "HEAD"]
  cache_policy_id        = aws_cloudfront_cache_policy.discovery.id
}
Enter fullscreen mode Exit fullscreen mode

The JWKS document can usually remain on the Cognito issuer host. Clients validating JWTs should follow jwks_uri and use the keys from the actual issuer.

Step 7: route CloudFront behaviors carefully

A useful distribution often has at least two origins:

Origin 1: Cognito hosted UI domain
Origin 2: S3 bucket for static discovery overrides
Enter fullscreen mode Exit fullscreen mode

Behaviors:

/.well-known/openid-configuration -> S3 static origin
/*                                  -> Cognito origin
Enter fullscreen mode Exit fullscreen mode

You might also choose to serve:

/.well-known/jwks.json
Enter fullscreen mode Exit fullscreen mode

through CloudFront as a proxy or static copy, but do that carefully. Stale JWKS can break token validation during key rotation. In many cases, leaving jwks_uri pointed at the Cognito issuer is simpler and safer.

Step 8: monitor the bypass control

The regional Cognito WAF should have very boring metrics:

  • Allowed requests with the secret header.
  • Blocked direct requests without the secret header.

Create alarms for unexpected spikes in blocked direct traffic. They may indicate scanners, old clients, bad documentation, or someone using the origin URL directly.

Also log enough CloudFront and WAF data to debug OAuth flows:

  • CloudFront standard or real-time logs
  • AWS WAF logs for the CloudFront Web ACL
  • AWS WAF logs for the Cognito-origin Web ACL
  • Cognito user pool logs where applicable

Be careful with auth logs. They can contain sensitive query strings such as OAuth code, state, or provider-specific values. Set retention and access controls accordingly.

Common failure modes

The first common failure is forgetting response header rewrites. Login starts at the pretty domain, then one redirect sends the browser back to the Cognito domain.

The second is changing the OIDC issuer in discovery without changing the actual token issuer. Many clients validate that issuer in discovery matches the iss claim in the token.

The third is caching auth responses. Do not cache OAuth redirects, token responses, or user-specific hosted UI pages unless you deeply understand the cache key.

The fourth is rotating the origin secret in only one place. CloudFront and the regional WAF must agree on the value.

The fifth is assuming the secret header is a complete security boundary. It is only a way to prevent ordinary direct-origin bypass. OAuth client configuration, redirect URI validation, PKCE, token validation, TLS, WAF rules, and monitoring still matter.

Final checklist

  • CloudFront alias is the public auth hostname.
  • CloudFront forwards required query strings, cookies, and headers to Cognito.
  • CloudFront adds a high-entropy custom origin header.
  • CloudFront WAF handles edge controls such as /login redirect, DDoS protection, login protection, bot controls, rate limits, managed rule groups, and custom rules.
  • CloudFront Function rewrites Cognito Location headers to the public hostname.
  • Regional WAF is associated with the Cognito user pool.
  • Regional WAF default action blocks.
  • Regional WAF allow rule matches the CloudFront secret header.
  • /.well-known/openid-configuration is served from the public hostname.
  • Discovery OAuth endpoints point to the public hostname.
  • issuer and jwks_uri remain compatible with the tokens Cognito actually issues.
  • Direct requests to the Cognito hosted UI domain are blocked.
  • OAuth flows are tested end to end with a real OIDC client.

This pattern is not about hiding identity infrastructure as a security trick. OIDC metadata and tokens still identify Cognito as the issuer. It is about making one public auth edge, putting controls there, keeping the hosted UI origin out of the normal browser path, and making direct-origin access boringly unavailable.

References

Top comments (0)