This article is the CloudFront Functions + CloudFront KeyValueStore (KVS) version of the previous article, Implementing Impossible Location Detection Without Application Changes Using Amazon CloudFront + Lambda@Edge.
If you have not read the Lambda@Edge version yet, I recommend reading it first. It will make the differences in this article much easier to understand.
Why CloudFront Functions?
In the previous Lambda@Edge version, I implemented Impossible Location detection without modifying the application.
However, Lambda@Edge has several limitations.
| Lambda@Edge limitation | CloudFront Functions version |
|---|---|
CloudFront-Viewer-Country is not available in Viewer Request |
Available in Viewer Request, with the right Origin Request Policy |
CachingDisabled is required for protected paths |
Cacheable responses can still be protected |
Deployment to us-east-1 is required |
No Lambda@Edge regional deployment constraint |
| Lambda@Edge costs $0.60 per 1 million requests | CloudFront Functions cost $0.10 per 1 million requests |
However, CloudFront Functions also require one important trade-off.
With this approach, the application needs to write location information to CloudFront KeyValueStore when login succeeds.
In other words, unlike the Lambda@Edge version, this version requires a small application-side change.
How It Works
In the Lambda@Edge version, an Origin Response Lambda automatically detected successful login responses and set a signed cookie.
In the CloudFront Functions version, the application writes the viewer’s country code and ASN to CloudFront KeyValueStore (KVS) when login succeeds.
After that, a CloudFront Function checks the KVS entry on each protected request.
At Login
When login succeeds, the application reads the CloudFront-Viewer-Country and CloudFront-Viewer-ASN headers and writes the session hash, country, and ASN to KVS.
This is the main difference from the Lambda@Edge version.
In the Lambda@Edge version, the edge layer automatically detects successful login responses.
In this version, the application explicitly writes the login-time location to KVS.
When Accessing Protected Pages
The biggest advantage is that verification is completed at Viewer Request.
CloudFront Functions can access CloudFront-Viewer-Country during the Viewer Request phase, so we do not need to wait until Origin Request.
This means CachingDisabled is no longer required for protected cache behaviors.
That is a major advantage.
Architecture Difference from the Lambda@Edge Version
The difference between the two versions can be summarized like this:
The Lambda@Edge version is useful when you cannot modify the application.
The CloudFront Functions + KVS version is useful when you can add a small amount of login-side logic and want better cost, latency, and cache compatibility.
Origin Request Policy Is Still Required
There is one important catch.
For CloudFront Functions to read CloudFront-Viewer-Country and CloudFront-Viewer-ASN at Viewer Request, the behavior must have an Origin Request Policy that includes these headers.
If the Origin Request Policy is not configured correctly, the headers will not be available to the CloudFront Function.
Depending on how you implement error handling, this may result in either:
- fail open, where requests are allowed without validation
- fail closed, where all users are blocked or redirected
Also, CloudFront-Viewer-ASN cannot be added through a Cache Policy. It must be handled through an Origin Request Policy.
In this sample, I use the managed policy:
AllViewerExceptHostHeader
b689b0a8-53d0-40ab-baf2-68738e2966ac
In CloudFront Functions runtime 2.0, these headers can be accessed like this:
function handler(event) {
var h = event.request.headers;
console.log(
'country: ' +
(h['cloudfront-viewer-country']
? h['cloudfront-viewer-country'].value
: 'undefined')
);
console.log(
'asn: ' +
(h['cloudfront-viewer-asn']
? h['cloudfront-viewer-asn'].value
: 'undefined')
);
return event.request;
}
After deployment, I recommend testing header delivery with a small validation function like this before enabling the full protection logic.
Default Protection Mode
This implementation uses a default protection model.
Instead of listing every protected path, we list only public paths.
module "ilg" {
source = "your-org/impossible-location-guard-cf/aws"
public_paths = ["/login", "/assets/*"]
}
Everything else is protected by default.
This follows the same principle as “default deny” in firewall design.
New pages are automatically protected unless they are explicitly listed as public paths.
Important Implementation Notes
Handling SPA and API Requests
Returning 302 redirects for API calls can break SPA behavior.
If the frontend uses fetch or XHR, it may receive the login page HTML and then fail when trying to parse it as JSON.
Recommended approach:
- Return
302for browser page requests - Return
401for API requests
For example, requests with Accept: application/json or paths under /api/* should usually receive 401 Unauthorized instead of a redirect.
Validate KVS Writes Carefully
Writing to KVS is the responsibility of the application.
Therefore, the application must write to KVS only after login succeeds.
If the application writes location data on failed login attempts, an attacker may be able to poison session data.
The rule is simple:
Only write to KVS after successful authentication.
Using ASN As Well
ASN validation can also be enabled.
module "ilg" {
source = "your-org/impossible-location-guard-cf/aws"
check_asn = true
}
Country-level detection is useful, but it is not always enough.
For example, an attacker may access the application from the same country as the victim by using a cloud provider or proxy in that country.
By also checking ASN, you may be able to detect suspicious access even when the country code is the same.
However, ASN changes can also happen during legitimate usage, such as when a user switches between mobile data and Wi-Fi.
For that reason, check_asn is disabled by default and should be enabled based on your service’s risk tolerance.
Reauthentication Instead of Hard Blocking
As with the Lambda@Edge version, I recommend redirecting users to reauthentication instead of hard-blocking them.
If a legitimate user is completely blocked due to a false positive, they may feel that the service is broken or unreliable.
On the other hand, if they are simply asked to log in again, they may think their session expired.
When suspicious access is detected:
- Redirect the user to
/login - The user logs in again
- The KVS entry is updated with the current country and ASN
- Access resumes
One advantage of the KVS version is that the KVS entry can be deleted on logout.
This enables immediate session-level invalidation.
Sample Code
Terraform
In the Lambda@Edge version, protected paths required CachingDisabled.
In the CloudFront Functions version, the function runs at Viewer Request. This means the location check is still executed even when the response is served from cache.
This is one of the biggest advantages of the CloudFront Functions version.
module "ilg" {
source = "your-org/impossible-location-guard-cf/aws"
version = "~> 1.0"
project_name = "myapp"
session_cookie_name = "session_id"
redirect_path = "/login"
public_paths = ["/login", "/assets/*"]
check_asn = true
}
resource "aws_cloudfront_distribution" "main" {
default_cache_behavior {
target_origin_id = "app"
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"
origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac"
function_association {
event_type = "viewer-request"
function_arn = module.ilg.cloudfront_function_arn
}
}
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"
}
}
The application runtime role also needs permission to write to KVS.
resource "aws_iam_role_policy_attachment" "app_kvs_write" {
role = aws_iam_role.app.name
policy_arn = module.ilg.kvs_write_policy_arn
}
Application-Side KVS Write
Here is an example using the AWS SDK for JavaScript v3.
import {
CloudFrontKeyValueStoreClient,
DescribeKeyValueStoreCommand,
PutKeyCommand,
} from '@aws-sdk/client-cloudfront-keyvaluestore';
import { createHash } from 'crypto';
const client = new CloudFrontKeyValueStoreClient({
region: 'us-east-1',
});
const KVS_ARN = process.env.ILG_KVS_ARN;
function sessionKey(sessionId) {
return createHash('sha256').update(sessionId).digest('hex');
}
async function registerLocation(sessionId, req, retries = 3) {
const country = req.headers['cloudfront-viewer-country'] || '';
const asn = req.headers['cloudfront-viewer-asn'] || '';
for (let i = 0; i < retries; i++) {
const { ETag } = await client.send(
new DescribeKeyValueStoreCommand({
KvsARN: KVS_ARN,
})
);
try {
await client.send(
new PutKeyCommand({
KvsARN: KVS_ARN,
Key: sessionKey(sessionId),
Value: `${country}:${asn}`,
IfMatch: ETag,
})
);
return;
} catch (e) {
if (i === retries - 1) {
throw e;
}
}
}
}
The session ID should not be used directly as the KVS key.
If someone has permission to read KVS entries, using the raw session ID as the key would expose session IDs.
Hashing the session ID reduces that risk.
Login Flow Example
app.post('/login', async (req, res) => {
const result = await authenticate(req.body);
if (result.ok) {
await registerLocation(result.sessionId, req);
res.cookie('session_id', result.sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
});
res.redirect('/dashboard');
} else {
res.status(401).render('login', {
error: 'Authentication failed',
});
}
});
If you use an external identity provider, such as an OIDC provider, this approach can still work as long as the callback URL goes through CloudFront.
The request flow would be:
Browser -> CloudFront -> application callback endpoint
CloudFront adds CloudFront-Viewer-Country and CloudFront-Viewer-ASN, and the callback handler can call registerLocation.
Logout Handling
The KVS version can delete the session’s location entry when the user logs out.
import { DeleteKeyCommand } from '@aws-sdk/client-cloudfront-keyvaluestore';
async function onLogout(sessionId) {
const { ETag } = await client.send(
new DescribeKeyValueStoreCommand({
KvsARN: KVS_ARN,
})
);
await client.send(
new DeleteKeyCommand({
KvsARN: KVS_ARN,
Key: sessionKey(sessionId),
IfMatch: ETag,
})
);
}
This is one of the advantages of the KVS version.
It enables immediate invalidation at the session level.
BFF Architecture
This approach also works with a BFF, or Backend for Frontend, architecture.
If the BFF calls a downstream authentication API, make sure the CloudFront behavior for the BFF uses an Origin Request Policy such as AllViewerExceptHostHeader.
That way, CloudFront-Viewer-Country and CloudFront-Viewer-ASN are forwarded from CloudFront to the BFF.
The BFF then writes to KVS.
app.post('/api/auth/login', async (req, res) => {
const result = await authApi.login(req.body);
if (result.ok) {
await registerLocation(result.sessionId, req);
// ...
}
});
One important point:
You should trust cloudfront-viewer-country and cloudfront-viewer-asn only when they are received directly from CloudFront.
KVS writes should be done by the process that directly receives these headers from CloudFront.
If you really need to relay those headers to another service, you should protect their integrity using mechanisms such as request signing, mTLS, private network controls, or strict origin access restrictions.
When the Authentication System Uses a Different Domain
Suppose your architecture uses separate subdomains, such as:
auth.example.com
api.example.com
In the Lambda@Edge version, you needed to share cookies across subdomains and associate Lambda@Edge functions with multiple distributions.
In the KVS version, the backend writes directly to KVS, so you do not need to associate Lambda functions across distributions in the same way.
However, the login request must go through CloudFront.
The country and ASN written to KVS come from CloudFront headers. If login does not pass through CloudFront, the application cannot obtain the same viewer location information used by the CloudFront Function.
Using your own GeoIP lookup instead may cause false positives because your GeoIP database may not match CloudFront’s GeoIP database.
A typical flow would be:
auth.example.com via CloudFront
-> login API
-> PutKey to KVS
api.example.com CloudFront Function
-> read the same KVS
The session cookie, such as session_id, must be shared with:
Domain=.example.com
Blocking Direct Access to the Origin
This mechanism assumes that the origin can only be accessed through CloudFront.
If attackers can directly access the origin, they can bypass the CloudFront Function entirely by sending requests directly to the origin with the stolen session cookie.
This is not just a header spoofing problem.
If CloudFront is bypassed, the validation logic does not run at all.
Examples of origin access protection include:
- For ALB: allow only the CloudFront managed prefix list,
com.amazonaws.global.cloudfront.origin-facing, in the security group - For S3: use Origin Access Control (OAC)
- For custom origins: add a secret header from CloudFront to the origin and validate it at the origin or with AWS WAF
Origin protection is a prerequisite for this design.
KVS Limitations
CloudFront KVS is suitable for small to medium-sized services.
Before using it for session-level state, you need to understand its limitations.
Capacity
CloudFront KVS has a 5 MB limit for the entire store.
It is suitable for small values such as configuration, feature flags, or in this case, session hash to location mapping.
Depending on the exact key and value size, the practical upper limit is likely on the order of tens of thousands of active sessions.
No TTL
KVS entries do not have a TTL.
If a user’s session expires without logout, the KVS key remains unless you delete it.
Therefore, you need a cleanup mechanism.
Write Rate and ETag Conflicts
PutKey uses optimistic concurrency control with ETags.
The typical flow is:
DescribeKeyValueStore
-> get ETag
-> PutKey with IfMatch
If many users log in at the same time, ETag conflicts may occur.
Retries are required.
Eventual Consistency
CloudFront KVS is eventually consistent.
After PutKey, the value may not be immediately readable from every edge location.
In most cases, propagation is fast, but if your login flow immediately redirects users to a protected page, there is a small chance that legitimate users may be redirected back to login because the KVS entry has not propagated yet.
Possible mitigations include:
- showing a short loading screen after login
- retrying the protected request
- showing a friendly message such as “Please try again”
Why Not DynamoDB?
You may wonder:
Wouldn’t DynamoDB be better for session-level state?
In many ways, yes.
| Topic | CloudFront KVS | DynamoDB |
|---|---|---|
| Capacity | 5 MB limit | Practically unlimited |
| TTL | No TTL | TTL attribute supported |
| Writes | ETag conflicts and retries | Simple PutItem overwrite |
| Read latency | A few milliseconds at the edge | Round trip to a region |
| Authentication | SigV4A, which can be tricky | Standard SigV4 |
So why use KVS?
The reason is latency and execution location.
KVS is available from CloudFront Functions at the edge. Reads can complete in a few milliseconds.
DynamoDB is regional. Reading from DynamoDB adds a network round trip from the edge to the AWS region.
More importantly, CloudFront Functions cannot call DynamoDB directly.
CloudFront Functions do not allow external network access.
So if you want to use DynamoDB, you would need to move the check to Lambda@Edge, usually at Origin Request.
That means:
-
CachingDisabledis required again - validation runs at Origin Request instead of Viewer Request
- latency increases
- Lambda@Edge pricing applies
At that point, the architecture becomes very similar to the Lambda@Edge version.
Architecture Comparison
| Topic | Lambda@Edge + Cookie | CloudFront Functions + KVS | Lambda@Edge + DynamoDB |
|---|---|---|---|
| Application change | Not required | KVS write required | DynamoDB write required |
| Cost per 1M requests | $0.60 | $0.10 + KVS $0.03 | $0.60 + DynamoDB |
| Execution phase | Origin Request | Viewer Request | Origin Request |
| Cache compatibility |
CachingDisabled required |
Cacheable |
CachingDisabled required |
| Scale | No practical session-store limit | 5 MB / tens of thousands of sessions | Scalable |
| TTL / cleanup | Cookie expiration | Manual cleanup | DynamoDB TTL |
| Immediate invalidation | Difficult | DeleteKey |
DeleteItem |
| Deployment constraint | Lambda@Edge in us-east-1
|
No Lambda@Edge regional constraint | Lambda@Edge in us-east-1
|
As of July 7, 2026.
Summary
CloudFront Functions can access CloudFront-Viewer-Country at Viewer Request, but the correct Origin Request Policy is required.
Using KVS removes the need for CachingDisabled, so cached content can still be protected.
A default protection model, where only public paths are listed, helps avoid accidental exposure of new pages.
ASN checking can help detect suspicious access even within the same country.
KVS is suitable for small to medium-sized services, but it has important limitations such as:
- 5 MB capacity
- no TTL
- write rate limits
- ETag conflicts
- eventual consistency
DynamoDB solves many of these scale and lifecycle problems, but CloudFront Functions cannot call DynamoDB directly. Using DynamoDB would bring the architecture back to Lambda@Edge.
BFF architectures can also use this approach, as long as the BFF writes to KVS using headers received directly from CloudFront.
Blocking direct access to the origin is a prerequisite. If attackers can bypass CloudFront, the entire validation logic can be bypassed.
Also, cloudfront-viewer-* headers should be trusted only by the process that receives them directly from CloudFront. They should not be blindly relayed to downstream services.
When I first thought of this idea, I expected it to be fairly simple.
But once I started considering the security details, there were many more things to think about than I expected.
Whether this is suitable for production depends on the service, traffic volume, authentication flow, and risk tolerance.
Still, I think it is worth considering as one possible way to detect session hijacking at the edge.



Top comments (0)