Skip to content
VPNDetection

Check IPs for VPNs in Zig with std.http.Server

Check each visitor's IP for a VPN from a Zig 0.16.0 std.http.Server, reading X-Forwarded-For by hand and freeing every Lookup the client returns.

Published 6 min read

On this page
  1. Add the library
  2. One client on the program's Io
  3. Answer a request
  4. Which address to look up
  5. Who frees a Lookup
  6. When a lookup fails
  7. Absent is not false

This tutorial builds a small Zig server on std.http.Server that looks up each visitor's address with vpndetection, our Zig client library, and asks visitors on a VPN, Tor or a residential proxy to confirm their email at signup. std.http.Server has no proxy setting and no middleware, so the address you look up is the one your code picks, and every answer is memory your code frees. The library supports Zig 0.16.0 and 0.17.0, and Zig's standard library still changes shape between releases, std.http and std.Io among them. We ran every snippet against the live API on October 22, 2026, with Zig 0.16.0 and vpndetection 4.9.0, and every output below is from those runs. The same main.zig, in a project from Zig 0.17.0's zig init, built and answered the same.

Add the library

bash
mkdir screen && cd screen
zig init
zig fetch --save git+https://github.com/vpndetection-io/sdk-zig#v4.9.0

zig fetch --save pins the release's commit and hash in build.zig.zon and unpacks the package into zig-pkg/. In build.zig, hand the module to the executable, right after const exe = b.addExecutable(...):

zig
    const vpndetection = b.dependency("vpndetection", .{
        .target = target,
        .optimize = optimize,
    });
    exe.root_module.addImport("vpndetection", vpndetection.module("vpndetection"));

Get an API key in the console: a free one covers 50,000 lookups a month. Without a key, the API allows 1,000 lookups a day per source address, and once deployed, every lookup comes from your server's address.

One client on the program's Io

Replace src/main.zig. Its main takes std.process.Init, which hands it an allocator, an Io and the environment, and the client is built from all three:

zig
const std = @import("std");
const vpndetection = @import("vpndetection");

const net = std.Io.net;
const Request = std.http.Server.Request;

/// Where nginx connects from. Only its X-Forwarded-For is believed.
const proxy: [4]u8 = .{ 172, 28, 5, 12 };

/// What each connection's task is handed.
const App = struct {
    gpa: std.mem.Allocator,
    io: std.Io,
    client: *vpndetection.Client,
};

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    var client = try vpndetection.Client.init(init.gpa, io, .{
        .api_key = init.environ_map.get("VPNDETECTION_API_KEY"),
        .timeout = .fromSeconds(2),
        .retries = 0,
    });
    defer client.deinit();
    const app: App = .{ .gpa = init.gpa, .io = io, .client = &client };

    const address = try net.IpAddress.parse("0.0.0.0", 8092);
    var server = try address.listen(io, .{ .reuse_address = true });
    defer server.deinit(io);

    var connections: std.Io.Group = .init;
    defer connections.cancel(io);
    while (true) {
        const stream = try server.accept(io);
        connections.concurrent(io, serve, .{ app, stream }) catch {
            stream.close(io);
        };
    }
}

In a Debug build, the default, init.gpa is a DebugAllocator, and init.io is a std.Io.Threaded. The client sends its requests and runs its timeout on that same Io. It's safe to share between tasks, so one client, with one answer cache, serves every connection. App holds a pointer to it, since a client must not be copied once it's built. Set proxy to the address your nginx connects from, 127.0.0.1 when it runs on the same machine.

connections.concurrent gives each connection a task of its own. Group.async looks the same and isn't: it may run the task on the spot, and std.Io.Threaded does once its pool, one thread fewer than the machine has CPUs, is busy. On one CPU that's every connection, so the accept loop serves them one at a time. We pinned the app to one CPU and pointed its api.vpndetection.io at an address that never answers. With async, a health check sent half a second after a signup waited 1.50 s, until the signup's lookup gave up. With concurrent, it answered in about 1 ms.

Answer a request

serve runs one connection, and handle one request on it:

zig
/// One connection, request after request, until the visitor closes it.
fn serve(app: App, stream: net.Stream) void {
    defer stream.close(app.io);
    var in_buffer: [8192]u8 = undefined;
    var out_buffer: [8192]u8 = undefined;
    var in = stream.reader(app.io, &in_buffer);
    var out = stream.writer(app.io, &out_buffer);
    var http: std.http.Server = .init(&in.interface, &out.interface);

    // What a request allocates lives until it has been answered.
    var arena: std.heap.ArenaAllocator = .init(app.gpa);
    defer arena.deinit();
    while (true) {
        defer _ = arena.reset(.retain_capacity);
        var request = http.receiveHead() catch return;
        const peer = stream.socket.address;
        handle(app, arena.allocator(), peer, &request) catch return;
    }
}

/// /check shows the answer, /signup decides, and nothing else is looked up.
fn handle(
    app: App,
    arena: std.mem.Allocator,
    peer: net.IpAddress,
    request: *Request,
) !void {
    // A request with neither Content-Length nor Transfer-Encoding has no body,
    // but respond() asserts one is set, and a failed assert ends the server.
    const head = &request.head;
    if (head.content_length == null and head.transfer_encoding == .none) {
        head.content_length = 0;
    }
    const check = std.mem.eql(u8, head.target, "/check");
    if (!check and !std.mem.eql(u8, head.target, "/signup")) {
        return request.respond("ok\n", .{});
    }

    var buffer: [64]u8 = undefined;
    const ip = try visitor(peer, request, &buffer);
    const body = answer(app.client, arena, ip, check) catch |err| switch (err) {
        error.OutOfMemory => return err,
        // Fail open: let the visitor in, but have signup ask for more.
        else => if (check) "null" else "{\"confirm_email\":true}",
    };
    return request.respond(body, .{ .extra_headers = &.{
        .{ .name = "content-type", .value = "application/json" },
    } });
}

The first lines of handle keep the server up. curl -X POST sends a POST with neither Content-Length nor Transfer-Encoding, which in HTTP/1.1 has an empty body, and respond asserts that one of the two is set. Without those lines, one such request panicked inside respond, and the server's process died with it. It's a known issue, fixed on Zig's master branch on October 12, after 0.17.0.

answer looks the visitor up and writes the response:

zig
/// The response for one visitor, built in the request's arena.
fn answer(
    client: *vpndetection.Client,
    arena: std.mem.Allocator,
    ip: []const u8,
    check: bool,
) vpndetection.CallError![]const u8 {
    var diagnostics: vpndetection.Diagnostics = .{};
    const call: vpndetection.CallOptions = .{ .diagnostics = &diagnostics };
    const result = client.lookupWith(ip, call) catch |err| {
        std.log.warn("lookup failed: {s}: {s}", .{
            vpndetection.kindName(err), diagnostics.message(),
        });
        return err;
    };
    defer result.deinit();

    const json: std.json.Stringify.Options = .{
        .emit_null_optional_fields = false,
    };
    if (check) return std.json.Stringify.valueAlloc(arena, result.value, json);
    // A flag your plan leaves out is null, and orelse false reads it as no.
    const masked = result.value.is_vpn or (result.value.is_tor orelse false) or
        (result.value.is_resproxy orelse false);
    return std.json.Stringify.valueAlloc(arena, .{ .confirm_email = masked }, json);
}

lookupWith is lookup with this call's own options, here a Diagnostics that holds the detail behind a failure. Start the app with your key:

bash
export VPNDETECTION_API_KEY=...
zig build run

From 141.98.252.67, a Mullvad server in London from Mullvad's own server list, part of /check read:

json
{
  "ip": "141.98.252.67",
  "is_vpn": true,
  "is_hosting": true,
  "is_tor": false,
  "is_resproxy": false,
  "vpn": {
    "provider": "mullvad",
    "last_seen": "2026-10-21",
    "confidence": "high"
  }
}

That's a key on the Max plan. The answer also has location and asn: the United Kingdom and AS39351 here. A home broadband address on a US cable ISP came back with every flag false, every detail object empty, and location and asn filled in. POST /signup answered {"confirm_email":true} to the Mullvad server and {"confirm_email":false} to the home line. A private or reserved address never reaches the API: the client answers it itself.

Which address to look up

std.http.Server has no idea of a proxy, so peer, the address accept returned, is the visitor only when nothing sits in between. visitor reads X-Forwarded-For by hand, and only on a connection from proxy:

zig
/// The visitor's address: the connection's own, unless the connection is
/// nginx's, which appends the address it saw to X-Forwarded-For.
fn visitor(
    peer: net.IpAddress,
    request: *const Request,
    buffer: []u8,
) ![]const u8 {
    if (peer == .ip4 and std.mem.eql(u8, &peer.ip4.bytes, &proxy)) {
        if (lastForwardedFor(request)) |entry| return entry;
    }
    return addressOnly(peer, buffer);
}

/// The right-most entry of the last X-Forwarded-For header, if there is one.
fn lastForwardedFor(request: *const Request) ?[]const u8 {
    var last: ?[]const u8 = null;
    var headers = request.iterateHeaders();
    while (headers.next()) |header| {
        if (!std.ascii.eqlIgnoreCase(header.name, "x-forwarded-for")) continue;
        var entries = std.mem.splitBackwardsScalar(u8, header.value, ',');
        last = std.mem.trim(u8, entries.first(), " ");
    }
    return last;
}

/// The address alone. Formatted with {f}, an IpAddress carries its port.
fn addressOnly(peer: net.IpAddress, buffer: []u8) ![]const u8 {
    return switch (peer) {
        .ip4 => |a| std.fmt.bufPrint(buffer, "{d}.{d}.{d}.{d}", .{
            a.bytes[0], a.bytes[1], a.bytes[2], a.bytes[3],
        }),
        .ip6 => |a| std.fmt.bufPrint(buffer, "{f}", .{
            net.Ip6Address.Unresolved{ .bytes = a.bytes, .interface_name = null },
        }),
    };
}

We put nginx 1.29.1 in front of the app, in a container of its own at 172.28.5.12, with the usual proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for. The Mullvad server sent requests through it and straight to the app, with and without an X-Forwarded-For: 1.1.1.1 of its own. The address each way of picking it looked up:

visitor readsThrough nginxThrough nginx, forgedStraight, forged
peer alonenginxnginxthe visitor
The left-most entrythe visitor1.1.1.11.1.1.1
The right-most entrythe visitorthe visitor1.1.1.1
The right-most, from nginxthe visitorthe visitorthe visitor

nginx appends the address it saw, so the right-most entry is the one nginx wrote, and a forged one stays to its left. Straight to the app, the only entry was the one the visitor wrote, and 1.1.1.1 answers is_hosting and is_cdn but not is_vpn, so signup didn't ask them to confirm. Reading the header only on a connection from proxy closed that.

Two ways to get this wrong are Zig's own. {f} writes an IpAddress with its port after it, so addressOnly writes the bytes itself: given {f}'s text, the API refused every visitor straight to the app as not a valid IP address. And visitor compares bytes because IpAddress.eql compares ports too: a check written with eql against nginx's address never matched, so every visitor through nginx was looked up as 172.28.5.12, a private address the client answers itself, with every flag false.

Who frees a Lookup

lookupWith returns a Lookup allocated with the allocator you gave Client.init, here init.gpa. Everything in it, the strings in value and the API's own JSON in raw, lives in the Lookup's own arena, which deinit frees. The request's arena in serve holds what handle allocates and is reset after each request, but the Lookup isn't in it. So answer frees the Lookup with defer result.deinit() and builds the response in the request's arena before that runs.

Nothing from a Lookup outlives its deinit. A version of answer that returned result.raw for /check, rather than writing a copy, handed respond memory the defer had just freed. On vpndetection 4.9.0 the first /check crashed the server with a segmentation fault. On 4.7.0 it had happened to answer, from memory nothing owned any more. A cached answer is no different: the client keeps the API's body and parses a new Lookup from it on every hit, which needs its own deinit.

A missing deinit crashes nothing: the server answered as before. std.testing.allocator is a DebugAllocator that fails a test which leaves memory behind. A documentation address is answered inside the client, so this test needs no network and no key, and its Lookup is allocated like any other:

zig
test "a lookup is freed" {
    const gpa = std.testing.allocator;
    var client = try vpndetection.Client.init(gpa, std.testing.io, .{});
    defer client.deinit();
    var arena: std.heap.ArenaAllocator = .init(gpa);
    defer arena.deinit();

    // A documentation address, answered by the client without a request.
    const body = try answer(&client, arena.allocator(), "192.0.2.10", false);
    try std.testing.expectEqualStrings("{\"confirm_email\":false}", body);
}

With the defer result.deinit() line deleted, zig build test failed, in part:

test
+- run test 1 pass (1 total); 2 leaks
error: 'main.test.a lookup is freed' leaked 2 allocations:
       [DebugAllocator] (err): memory address 0x7fcf9f840020 leaked:
       ...
       /screen/src/main.zig:137:37: 0x12a8ca9 in answer (main.zig)
           const result = client.lookupWith(ip, call) catch |err| {
                                           ^
       /screen/src/main.zig:162:28: 0x129cde8 in test.a lookup is freed (main.zig)
           const body = try answer(&client, arena.allocator(), "192.0.2.10", false);
                                  ^
       ...
Build Summary: 4/6 steps succeeded (1 failed); 2/2 tests passed

The test's own check passed, and the build failed on the two allocations left behind, each reported with the stack that made it. With the line back, zig build test passed. init.gpa checks for leaks only as main returns, which a server stopped with Ctrl-C never does, and ours logged nothing.

When a lookup fails

The client's defaults are 30 seconds an attempt and two retries, which suits a script better than a signup form. We pointed the app's api.vpndetection.io at an address that never answers: with the defaults, signup answered after 90.75 s, and with the 2-second timeout and no retries in main, after 2.00 s. Pointed at one that refuses connections, it took 0.76 s with the defaults and at most 14 ms with ours.

Each time, answer logged the reason it read from Diagnostics, such as warning: lookup failed: network: the request timed out after 2000 ms, and handle failed open: signup asked for an email confirmation, and /check answered null. With an invalid key, the log read warning: lookup failed: unauthorized: invalid API key, and signup did the same.

The client keeps answers for an hour, up to 10,000 addresses. Counted at a proxy that logged every call to our API, from a fresh start, 50 signups from the Mullvad server cost one lookup, and 20 requests to /healthz cost none. A free key covers 50,000 lookups a month, and paid plans start at 250,000 (pricing). With a few million distinct visitors a day, download the VPN IP database and look addresses up locally instead of calling the API.

Absent is not false

Started without a key, the app's /check for the Mullvad server read:

json
{
  "ip": "141.98.252.67",
  "is_vpn": true
}

Every other field of the Answer is null, which /check leaves out, and null means "not in your plan", not "checked, and no". orelse false in answer reads it as no, so with no key 190.120.229.98, a Tor exit from the Tor Project's list, signed up without confirming an email. With a key, its is_tor came back true and signup asked. Compare with null where the difference matters, as in result.value.is_tor == null. is_vpn comes back without a key, a free key adds the Tor and relay flags, location and asn, and every paid plan answers every field.

The library's README covers batch lookups and database downloads too.

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.