DEV Community

Cover image for Implementing Impossible Location Detection with Amazon CloudFront Functions + KVS

Implementing Impossible Location Detection with Amazon CloudFront Functions + KVS

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

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

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:

Architecture Difference from the Lambda@Edge Version

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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/*"]
}
Enter fullscreen mode Exit fullscreen mode

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 302 for browser page requests
  • Return 401 for 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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Redirect the user to /login
  2. The user logs in again
  3. The KVS entry is updated with the current country and ASN
  4. 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
}
Enter fullscreen mode Exit fullscreen mode
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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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',
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

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);

    // ...
  }
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The session cookie, such as session_id, must be shared with:

Domain=.example.com
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  • CachingDisabled is 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)