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.
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.
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.
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"}]
}
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;
};
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"]
}
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:
- Log in with their own account
- Obtain a valid
_ilg_loccookie signed for the attacker’s country - Steal a victim’s session cookie
- 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
}
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"]
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);
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
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
302for normal browser page requests - Return
401for 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
}
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:
- Redirect the user to
redirect_path, such as/login - The user logs in again
- The location cookie is overwritten with the current country and ASN
- 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
}
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 ...
}
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
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"
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
}
# 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
}
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-Countryis 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)