Skip to content
VPNDetection

How to detect VPN users in AWS Lambda with Node.js

Screen signups for VPN and Tor users in AWS Lambda with Node.js, tested: the event field each trigger fills, a forged X-Forwarded-For, CloudFront and timeouts.

Published 5 min read

On this page
  1. The function
  2. Read sourceIp, not X-Forwarded-For
  3. Behind CloudFront
  4. Cold starts and the cache
  5. A timeout under the function's
  6. Absent is not false
  7. Next steps

This tutorial screens signups for VPN and Tor users in an AWS Lambda function written in Node.js, with vpndetection, our official client library. There's no framework in front of the function and so no middleware: it reads the visitor's address from the event, looks it up and answers. We ran every snippet on October 15, 2026, with vpndetection 5.5.2 in AWS's own Node.js 24 image for Lambda, the nodejs24.x runtime, under its runtime interface emulator, against the live API, and every output below is from those runs. We didn't deploy to an AWS account: the events are the shapes AWS documents for each trigger, trimmed to the fields the function reads, and what API Gateway and CloudFront put in them is quoted from AWS's docs.

The function

bash
npm install vpndetection

The library needs Node.js 22 or newer, so the nodejs22.x and nodejs24.x runtimes both run it. Get an API key in the console and give it to the function as the environment variable VPNDETECTION_API_KEY: a free one covers 50,000 lookups a month.

index.mjs:

js
import { VPNDetection, VPNDetectionError } from 'vpndetection';

// Built once per execution environment, so its cache outlives each invocation.
const client = new VPNDetection({
    apiKey: process.env.VPNDETECTION_API_KEY,
    timeoutMs: 1000, // see "A timeout under the function's"
    retries: 0,
});

export const handler = async (event) => {
    const ip = clientIp(event);
    let result;
    try {
        result = await client.lookup(ip);
    } catch (err) {
        if (!(err instanceof VPNDetectionError)) throw err;
        console.log(`vpndetection ${err.kind}: ${err.message}`);
        return respond(200, { checked: ip, allowed: true, screened: false });
    }
    if (result.isVpn || result.isTor) {
        return respond(403, { checked: ip, allowed: false });
    }
    return respond(200, { checked: ip, allowed: true });
};

// HTTP API and function URL (payload 2.0), then REST API (1.0).
function clientIp(event) {
    return event.requestContext.http?.sourceIp
        ?? event.requestContext.identity.sourceIp;
}

function respond(statusCode, body) {
    return {
        statusCode,
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(body),
    };
}

The client is built at module scope, outside the handler. Lambda runs that code once per execution environment and reuses the environment for the invocations after, so the client and the answers it caches carry over to each warm invocation. timeoutMs and retries keep a slow lookup inside Lambda's default timeout, which the last sections test.

To run it locally, start AWS's image with the function's directory mounted, passing the key through from your shell:

bash
docker run --rm -p 9000:8080 -v "$PWD":/var/task:ro -e VPNDETECTION_API_KEY \
  public.ecr.aws/lambda/nodejs:24 index.handler

Then send it an event. This is what an HTTP API sends for a visitor on 193.32.248.66, a Mullvad server in Berlin from Mullvad's own server list, who also wrote their own X-Forwarded-For: 1.1.1.1. http-api.json:

json
{
  "version": "2.0",
  "headers": { "x-forwarded-for": "1.1.1.1, 193.32.248.66" },
  "requestContext": { "http": { "sourceIp": "193.32.248.66" } }
}
bash
URL=localhost:9000/2015-03-31/functions/function/invocations
curl -s $URL -d @http-api.json | jq -c '{statusCode, body: (.body | fromjson)}'
json
{"statusCode":403,"body":{"checked":"193.32.248.66","allowed":false}}

We also sent the function a REST API event for the same visitor, a Tor exit from the Tor Project's exit list and a home broadband address on a US cable ISP:

VisitorEventStatus
Mullvad serverHTTP API403
Mullvad serverREST API403
Tor exit 190.120.229.98HTTP API403
Home broadbandHTTP API200

That's with a key on the Max plan. The Mullvad server answered isVpn with the provider mullvad, the Tor exit isTor, and the home address every flag false.

Read sourceIp, not X-Forwarded-For

Each trigger puts the visitor's address in a field of its own, and AWS describes each one the same way: "the source IP address of the immediate TCP connection making the request".

TriggerThe visitor's address
HTTP API, payload 2.0event.requestContext.http.sourceIp
Lambda function URLevent.requestContext.http.sourceIp
REST APIevent.requestContext.identity.sourceIp
CloudFront in front of any of themthe CloudFront-Viewer-Address header

The answers that rank for this read X-Forwarded-For instead and take its first entry, as the request-ip package does. On Lambda, that entry is whatever the visitor wrote. API Gateway appends the address it sees to the end of the header, as CloudFront and AWS's load balancers do, so a header the visitor sent stays in front. We ran a copy of the function with this clientIp:

js
// What the top answers do: the first X-Forwarded-For entry.
function clientIp(event) {
    return event.headers['x-forwarded-for'].split(',')[0].trim();
}

The same event got in:

json
{"statusCode":200,"body":{"checked":"1.1.1.1","allowed":true}}

It looked up 1.1.1.1, Cloudflare's resolver, which answers isHosting and isCdn but not isVpn, so the Mullvad visitor got in. Any address the visitor writes there is looked up the same way, so read sourceIp, the field API Gateway fills from the connection itself.

Behind CloudFront

With CloudFront in front of the API or the function URL, the connection AWS sees is CloudFront's, so sourceIp is a CloudFront address. This is the event for the same Mullvad visitor, from 130.176.88.10, in a range AWS publishes for CloudFront's connections to origins. cloudfront.json:

json
{
  "version": "2.0",
  "headers": { "cloudfront-viewer-address": "193.32.248.66:46532" },
  "requestContext": { "http": { "sourceIp": "130.176.88.10" } }
}

The function looked up CloudFront and let the visitor in:

json
{"statusCode":200,"body":{"checked":"130.176.88.10","allowed":true}}

That address answers isCdn, provider amazon_cloudfront, and every visitor arrives from one like it, so nobody is ever a VPN. CloudFront sends the visitor's address in the CloudFront-Viewer-Address header, with the source port after it (198.51.100.10:46532 in AWS's example), once you add the header to the distribution's origin request policy. In payload 2.0, header names arrive in lower case, as AWS's HTTP API guide notes:

js
// Behind CloudFront: the viewer's address, without the port after it.
function clientIp(event) {
    const viewer = event.headers['cloudfront-viewer-address'];
    return viewer.slice(0, viewer.lastIndexOf(':'));
}
json
{"statusCode":403,"body":{"checked":"193.32.248.66","allowed":false}}

That header is only as trustworthy as the function is closed. Sent straight to the function URL from the Mullvad server, with cloudfront-viewer-address: 1.1.1.1:443, the same copy looked up 1.1.1.1 and let it in. So let only CloudFront reach the function: for a function URL, that's CloudFront's origin access control, which needs the URL's auth type set to AWS_IAM. AWS notes the catch for a signup form: a POST through it must carry the SHA-256 of its body in x-amz-content-sha256, so the browser has to compute it.

Cold starts and the cache

The client caches each answer for an hour, up to 10,000 addresses, and private and reserved addresses never leave the function. The cache belongs to the execution environment, so a cold start begins with an empty one, and two environments running at once each pay for their own lookup of a visitor. We started the emulator three times and timed the handler from its report lines:

InvocationHandler duration
First lookup in a new environment160 to 210 ms
A new address, warm environment8 to 17 ms
The same address againunder 2 ms

The first lookup includes opening the connection to the API. On Lambda, the numbers depend on how far your Region is from the API. Each uncached lookup is one request against your plan: a free key covers 50,000 a month and paid plans start at 250,000 (pricing).

A timeout under the function's

Lambda stops a function after 3 seconds unless you set a longer timeout. The client's own defaults are longer: 30 seconds an attempt, and two retries. We pointed api.vpndetection.io at an address that never answers, gave the emulator Lambda's default timeout, and sent the Mullvad event to each setting:

bash
docker run --rm -p 9000:8080 -v "$PWD":/var/task:ro -e VPNDETECTION_API_KEY \
  -e AWS_LAMBDA_FUNCTION_TIMEOUT=3 --add-host api.vpndetection.io:10.255.255.1 \
  public.ecr.aws/lambda/nodejs:24 index.handler
Client optionsAnswerTime
apiKey aloneTask timed out after 3.00 seconds3.1 s
plus timeoutMs: 1000Task timed out after 3.00 seconds3.1 s
plus retries: 0200, "screened":false1.1 s

A timed-out function returns nothing, and the signup fails with it. timeoutMs alone isn't enough: the three attempts and the waits between them took 6.1 seconds on a function given 10. With both options, the lookup gives up after a second, the function logs vpndetection network: request timed out after 1000ms, and the signup goes through with screened: false, for you to review later. With the API refusing connections instead, it answered in 0.13 seconds and logged vpndetection network: no response from the API.

Absent is not false

Started with no key, the same function still refused the Mullvad server, but let the Tor exit in:

json
{"statusCode":403,"body":{"checked":"193.32.248.66","allowed":false}}
{"statusCode":200,"body":{"checked":"190.120.229.98","allowed":true}}

Without a key, a lookup answers ip and isVpn, plus isBogon, which the library sets itself. isTor isn't false there. It's undefined, which means "not in your plan", not "checked, and no", and result.isVpn || result.isTor reads undefined and false the same way. A free key adds the Tor and relay flags, and every paid plan answers every field, a proxy exit's evidence, such as how often we've seen it, included. In your own code, result.isTor === false means checked and not Tor, and result.isTor === undefined means your plan doesn't check it.

Next steps

Our Express and Next.js tutorials do the same on a Node.js server, where middleware runs the check on each request. The client library is on npm, with its source on GitHub, and every field the API answers is in the docs.

Written by

  • Uman Shahzad

    Founder & CEO

    IP intelligence data expert for over 8 years, former lead engineer at IPinfo, Uman has pioneered many systems and practices used by IP data companies to operate at scale, provide high-quality data and offer the best developer experiences.

Get new posts by email

Or follow along in a feed reader.

RSS feed

Try it on your own traffic.

The detection these posts describe is one API call away, with SDKs for every major language.

00 · No key

Start without signing up.

Paste it into a terminal. No account needed. 1,000 lookups a day per address, answering ip and is_vpn.

curl "https://api.vpndetection.io/146.70.116.130"
{
"ip": "146.70.116.130",
"is_vpn": true
}
01 · Free key

Sign up and get 50,000 requests a month.

No card and no sales call: the key is issued on signup.
Every request flags VPN IPs.
Upgrade only when you outgrow the allowance.