DEV Community

Cover image for Implementing Impossible Location Detection Without Application Changes Using Amazon CloudFront + Lambda@Edge

Implementing Impossible Location Detection Without Application Changes Using Amazon CloudFront + Lambda@Edge

When a session is compromised, where does the attacker access your application from?

For example, if a session cookie is stolen through XSS, an attacker may be able to impersonate the victim simply by setting that cookie in their own browser. In many cases, that access comes from a different country, network, or cloud provider than the legitimate user normally uses.

This is where the idea of Impossible Location comes in.

If a user logs in from Tokyo and then accesses the application from New York five minutes later, that movement is physically impossible. In that case, session hijacking should be considered highly suspicious.

However, I feel that this kind of detection is not always implemented in web applications, especially as a generic protection layer outside the application itself.

In this article, I will introduce an approach for detecting and responding to Impossible Location access using Amazon CloudFront and Lambda@Edge, without modifying the application.

This is only one possible design. I do not think this is the only correct answer, and there may be better architectures depending on your application and authentication flow. Please consider this article as one implementation idea.

Also, this implementation detects a change in country and optionally ASN from the time of login. It does not calculate physical travel speed. For example, even if a legitimate user travels abroad 12 hours after logging in, they may be asked to reauthenticate.

That said, this design redirects users to reauthentication instead of hard-blocking them, so the impact on legitimate users should be relatively small.

This mechanism is designed to help mitigate session hijacking.

It does not prevent password compromise itself. For leaked passwords, MFA is still one of the most effective countermeasures. I recommend using this kind of location-based session protection together with MFA.

This article covers the Lambda@Edge version.

If you can add a small amount of logic to the application during login, another approach using Amazon CloudFront Functions + CloudFront KeyValueStore (KVS) is also possible. That version has advantages in cost and latency, so I will cover it in a separate article.


How It Works

The basic idea is simple.

At login time, we store the viewer’s country information in a signed cookie.

After login, we compare the current viewer location with the location stored at login.

If the country, or optionally the ASN, changes unexpectedly, we redirect the user to the login page and ask them to reauthenticate.


At Login

When the login API returns a successful response, an Origin Response Lambda@Edge function intercepts the response.

The Lambda function reads the CloudFront-Viewer-Country header, signs the value with HMAC-SHA256, and sets it as a cookie.

Because this is done at the CloudFront layer, the application itself does not need to be modified.

Login flow: setting a signed location cookie with Origin Response Lambda@Edge


When Accessing Protected Pages

For authenticated pages, an Origin Request Lambda@Edge function verifies the signed location cookie.

No external GeoIP database is required.

No external API call is required.

Amazon CloudFront automatically provides the viewer country through the CloudFront-Viewer-Country header. The Lambda@Edge function only needs to compare that value with the signed cookie.

Protected page flow: verifying location with Origin Request Lambda@Edge


The “Oh, That’s Why!” Moment

At first, I thought the security check should be implemented in a Viewer Request Lambda@Edge function.

That sounds like the ideal place to block suspicious requests, right?

Viewer Request is the earliest event in the CloudFront request lifecycle, so it feels like the best place to reject malicious requests before they go any further.

However, when I actually tested it, the CloudFront-Viewer-Country header was not available in the Viewer Request Lambda.

Even after adding it to the Cache Policy and Origin Request Policy, and even after creating a debug endpoint, the value was always null.

After some investigation, I found the reason in the AWS documentation:

CloudFront adds the CloudFront-Viewer-Country header after the viewer request event.

CloudFront header timing and cache behavior

In other words, CloudFront determines the viewer country after the Viewer Request event.

So at the time the Viewer Request Lambda runs, the CloudFront-Viewer-Country header does not exist yet.

Adding the header to a Cache Policy or Origin Request Policy means that the header can be made available from the Origin Request phase onward. It does not make the header available to Viewer Request.

The same limitation applies even if you configure the policies correctly.

Also, CloudFront-Viewer-ASN cannot be added to a Cache Policy. It can only be included through an Origin Request Policy.

Because of this limitation, I changed the architecture so that the location check runs in an Origin Request Lambda@Edge function.


Why CachingDisabled Is Required

For protected paths, CachingDisabled is required.

This is an important point.

Origin Request Lambda@Edge is invoked only when CloudFront forwards the request to the origin. If CloudFront serves the response from cache, the Origin Request Lambda does not run.

That means if a protected page is cacheable and CloudFront returns a cache hit, the security check can be bypassed.

For authenticated pages, disabling cache is usually expected anyway, so this may not be a big issue in many applications.

However, if you want to protect cacheable content, you need to be careful.

This limitation is one of the reasons why the CloudFront Functions + KVS version can be attractive. CloudFront Functions run at Viewer Request, so they can run even when the response is served from cache.


Choosing the Origin Request Policy

To receive CloudFront-Viewer-Country and CloudFront-Viewer-ASN in an Origin Request Lambda, the CloudFront behavior must use an Origin Request Policy that forwards those headers.

One thing to be careful about is the Host header.

The managed policy AllViewerAndCloudFrontHeaders-2022-06 includes CloudFront headers, but it also forwards the viewer’s original Host header.

This can cause problems with origins such as API Gateway, Lambda Function URLs, or S3 with OAC.

For example, API Gateway and Lambda Function URLs expect the origin domain name in the Host header. If CloudFront forwards the viewer-facing host instead, the origin may return a 403 error.

For S3 with OAC, forwarding the wrong Host header may also cause a signature mismatch.

On the other hand, the managed policy AllViewerExceptHostHeader excludes the viewer’s Host header. This makes it safer to use with API Gateway, Lambda Function URLs, and S3 origins.

The AWS documentation says that this policy includes CloudFront viewer location headers such as CloudFront-Viewer-Country, although the documentation is a little inconsistent because the detailed list does not clearly show every CloudFront header.

In my testing, I confirmed that both CloudFront-Viewer-Country and CloudFront-Viewer-ASN were available in the Origin Request Lambda when using AllViewerExceptHostHeader.

I also confirmed that the Host header was replaced with the S3 origin domain name, so it did not break OAC signing.

{
  "country": [{"key": "CloudFront-Viewer-Country", "value": "JP"}],
  "asn":     [{"key": "CloudFront-Viewer-ASN",     "value": "17676"}],
  "host":    [{"key": "Host", "value": "xxx.s3.ap-northeast-1.amazonaws.com"}]
}
Enter fullscreen mode Exit fullscreen mode

Because the documentation can be slightly confusing here, I recommend validating header delivery with a small debug Lambda before enabling this mechanism in production.

exports.handler = async (event) => {
  const headers = event.Records[0].cf.request.headers;

  console.log(
    'country:',
    JSON.stringify(headers['cloudfront-viewer-country'])
  );

  console.log(
    'asn:',
    JSON.stringify(headers['cloudfront-viewer-asn'])
  );

  return event.Records[0].cf.request;
};
Enter fullscreen mode Exit fullscreen mode

Default Protection Mode

In this sample design, I use a default protection model.

Instead of listing every path that should be protected, we list only public paths.

For example:

module "impossible_location_guard" {
  source       = "your-org/impossible-location-guard/aws"
  login_paths  = ["/api/auth/login"]
  public_paths = ["/", "/login"]
}
Enter fullscreen mode Exit fullscreen mode

Everything except the listed public paths is protected.

This is similar to the “default deny” approach in firewall design.

The reason is simple: it is easy to forget to add newly created pages to a protected path list. If that happens, the new page may accidentally become unprotected.

By listing only public paths, new pages are protected by default.


Important Implementation Notes

Include the Session ID in the HMAC Signature

The signed cookie should not contain only the country and ASN.

If the HMAC payload only includes country and ASN, a replacement attack may become possible.

For example, an attacker could:

  1. Log in with their own account
  2. Obtain a valid _ilg_loc cookie signed for the attacker’s country
  3. Steal a victim’s session cookie
  4. Combine the victim’s session cookie with the attacker’s valid location cookie

To prevent this, the signed payload should include a value derived from the session ID, such as a hash of the session ID.

For example:

{
  "country": "JP",
  "asn": "12345",
  "sid": "sha256(sessionId)",
  "iat": 1234567890,
  "exp": 1234654290
}
Enter fullscreen mode Exit fullscreen mode

This binds the location cookie to the actual session.

To verify this value, the Origin Request Lambda needs to know the name of the session cookie. In the sample module, this is configured through session_cookie_name.


Set the Location Cookie Only on Successful Login

Origin Response Lambda@Edge runs for all origin responses, including error responses such as 400 or 401.

If the Lambda sets the location cookie without checking the response status code, even a failed login attempt could receive a valid location cookie.

That is not what we want.

In this sample implementation, the cookie is set only when the origin returns a successful status code, such as 200 or 201.

For example, the module has a success_statuses setting with a default value like:

["200", "201"]
Enter fullscreen mode Exit fullscreen mode

Manage the HMAC Secret Carefully

Lambda@Edge does not support environment variables in the same way as normal Lambda functions.

In this sample, the HMAC secret is bundled into the Lambda package as config.json at Terraform build time.

const config = require('./config.json');

if (!config.HMAC_SECRET) {
  throw new Error('HMAC_SECRET is not configured');
}

Object.assign(process.env, config);
Enter fullscreen mode Exit fullscreen mode

If the secret is missing, the function should fail closed.

Failing open would mean allowing requests without a valid security check, which is dangerous.

When sharing this mechanism across subdomains, Lambda functions generated from the same module can share the same HMAC secret.


Recommended Cookie Attributes

The location cookie should have secure attributes.

I recommend at least the following:

Secure
HttpOnly
SameSite=Lax
Enter fullscreen mode Exit fullscreen mode

Secure ensures the cookie is sent only over HTTPS.

HttpOnly prevents JavaScript from reading or modifying the cookie, which helps reduce the impact of XSS-based tampering attempts.

SameSite=Lax helps mitigate CSRF in many common cases.


Handling SPA and API Requests

If your frontend is an SPA, it may call APIs using fetch or XHR.

In that case, returning a 302 redirect for an API request can be inconvenient. The frontend may receive HTML from the login page and then fail when trying to parse it as JSON.

A more practical approach is:

  • Return 302 for normal browser page requests
  • Return 401 for API requests

For example, if the request path starts with /api/ or the Accept header includes application/json, returning 401 Unauthorized may be better than redirecting to /login.


Why ASN Can Help

Country-based detection is useful, but it is not always enough.

For example, in a large country such as the United States, an attacker may access the application from the same country as the victim.

The same can happen in Japan.

A legitimate user may log in from a home ISP or an office network, while an attacker may use a cloud provider in the same country. In both cases, the country code may be JP.

If we only compare the country code, that attack may not be detected.

To improve detection, we can also compare the ASN.

CloudFront provides the CloudFront-Viewer-ASN header, so the Origin Request Lambda can use it in the same way as the country header.

module "impossible_location_guard" {
  source    = "your-org/impossible-location-guard/aws"
  check_asn = true
}
Enter fullscreen mode Exit fullscreen mode

In my sample design, check_asn is disabled by default.

This is because ASN changes can happen during legitimate usage. For example, a user may switch between mobile data and Wi-Fi, which can result in a different ASN.

So ASN checking is a trade-off.

For some applications, country-level detection may be enough. For other applications, especially those with higher security requirements, ASN checking may be worth enabling.


Reauthentication Instead of Hard Blocking

My recommendation is to redirect users to reauthentication instead of hard-blocking them.

If a legitimate user is mistakenly blocked, they may feel that the service is broken or unreliable.

On the other hand, if they are asked to log in again, they may simply think that their session expired.

This is often a more acceptable user experience.

When a suspicious location change is detected, the behavior is:

  1. Redirect the user to redirect_path, such as /login
  2. The user logs in again
  3. The location cookie is overwritten with the current country and ASN
  4. Access is restored

This means that legitimate users who travel abroad or use a VPN only need to log in again.

An attacker who only has the stolen session cookie cannot continue unless they also have valid authentication credentials.


Sample Terraform Configuration

The application does not need to be modified.

You only need to add the module and associate the Lambda@Edge functions with the appropriate CloudFront cache behaviors.

# -----------------------------------------------
# impossible-location-guard module
# -----------------------------------------------
module "impossible_location_guard" {
  source  = "your-org/impossible-location-guard/aws"
  version = "~> 1.0"

  providers = {
    aws.us_east_1 = aws.us_east_1
  }

  project_name  = "myapp"
  login_paths   = ["/api/auth/login"]
  redirect_path = "/login"
  public_paths  = ["/", "/login"]
  check_asn     = true
  cookie_domain = ".example.com"

  create_cloudfront_distribution = false
}
Enter fullscreen mode Exit fullscreen mode

Lambda@Edge must be deployed in us-east-1, so the module requires a provider for that region.

Here is an example of adding the functions to an existing CloudFront distribution.

resource "aws_cloudfront_distribution" "main" {
  # ... existing origin configuration ...

  default_cache_behavior {
    target_origin_id       = "s3"
    viewer_protocol_policy = "redirect-to-https"

    allowed_methods = ["GET", "HEAD"]
    cached_methods  = ["GET", "HEAD"]

    cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # CachingDisabled

    origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac" # AllViewerExceptHostHeader

    lambda_function_association {
      event_type   = "origin-request"
      lambda_arn   = module.impossible_location_guard.origin_request_lambda_arn
      include_body = false
    }
  }

  ordered_cache_behavior {
    path_pattern           = "/assets/*"
    target_origin_id       = "s3"
    viewer_protocol_policy = "redirect-to-https"

    allowed_methods = ["GET", "HEAD"]
    cached_methods  = ["GET", "HEAD"]

    cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # CachingOptimized
  }

  ordered_cache_behavior {
    path_pattern           = "/api/auth/login"
    target_origin_id       = "api"
    viewer_protocol_policy = "redirect-to-https"

    allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods  = ["GET", "HEAD"]

    cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # CachingDisabled

    origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac" # AllViewerExceptHostHeader

    lambda_function_association {
      event_type   = "origin-response"
      lambda_arn   = module.impossible_location_guard.origin_response_lambda_arn
      include_body = false
    }
  }

  # Note:
  # If you have other API paths such as /api/users,
  # add a separate ordered_cache_behavior for /api/*.
  #
  # That behavior should use the API origin,
  # allow the required HTTP methods,
  # and use the origin-request Lambda for protection.

  # ... restrictions, viewer_certificate ...
}
Enter fullscreen mode Exit fullscreen mode

When the Authentication System Uses a Different Domain

One case that requires additional consideration is when the authentication system and application use different domains.

For example:

auth.example.com
api.example.com
Enter fullscreen mode Exit fullscreen mode

Because this mechanism stores location information in a cookie, the cookie must be shared between subdomains.

In that case, you can set:

cookie_domain = ".example.com"
Enter fullscreen mode Exit fullscreen mode

If the CloudFront distributions are also separate, the Lambda@Edge functions must be associated with the appropriate distributions.

For example:

# Distribution for auth.example.com
# Set the cookie in the login response
lambda_function_association {
  event_type = "origin-response"
  lambda_arn = module.ilg.origin_response_lambda_arn
}
Enter fullscreen mode Exit fullscreen mode
# Distribution for api.example.com
# Verify the location cookie on protected requests
lambda_function_association {
  event_type = "origin-request"
  lambda_arn = module.ilg.origin_request_lambda_arn
}
Enter fullscreen mode Exit fullscreen mode

Because both Lambda functions are generated from the same module, they can share the same HMAC secret.

This allows the cookie set on the authentication side to be verified on the application side.

However, in real environments, the authentication system and the application may belong to different AWS accounts or different teams. In that case, coordination is required.

Also, each application that relies on this protection must be configured properly. Otherwise, some paths or applications may remain unprotected.


Summary

In this article, I introduced one possible way to implement Impossible Location detection using Amazon CloudFront and Lambda@Edge without modifying the application.

The key points are:

  • CloudFront can provide viewer location information through headers such as CloudFront-Viewer-Country
  • CloudFront-Viewer-Country is not available in Viewer Request Lambda@Edge
  • The location check must run in Origin Request Lambda@Edge
  • Protected paths must use CachingDisabled, otherwise cache hits can bypass the Origin Request Lambda
  • Signing the location cookie with HMAC is important
  • The signed payload should be bound to the session ID
  • ASN checking can help detect suspicious access from the same country
  • Reauthentication is usually more user-friendly than hard blocking

The term “Impossible Location” may be familiar, but concrete implementation examples are not always easy to find.

That is why I built this as one possible approach.

I hope this article helps someone design a better implementation or think more deeply about session hijacking detection at the edge.

In the next article, I will cover another version using Amazon CloudFront Functions + CloudFront KeyValueStore.

That approach requires a small application-side change during login, but it can run at Viewer Request, avoid CachingDisabled, and provide better cost and latency characteristics.

Top comments (0)