# 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.

This tutorial builds a small Zig server on `std.http.Server` that looks up each visitor's address with [`vpndetection`](https://docs.vpndetection.io/integrations/zig), 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](https://app.vpndetection.io): 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](https://codeberg.org/ziglang/zig/issues/30231), 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](https://mullvad.net/en/servers), 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` reads | Through nginx | Through nginx, forged | Straight, forged |
|---|---|---|---|
| `peer` alone | nginx | nginx | the visitor |
| The left-most entry | the visitor | `1.1.1.1` | `1.1.1.1` |
| The right-most entry | the visitor | the visitor | `1.1.1.1` |
| The right-most, from nginx | the visitor | the visitor | the 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](https://vpndetection.io/pricing)). With a few million distinct visitors a day, download the [VPN IP database](https://vpndetection.io/databases/vpn-ip) and look addresses up locally instead of calling the [API](https://vpndetection.io/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](https://check.torproject.org/torbulkexitlist), 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](https://github.com/vpndetection-io/sdk-zig) covers batch lookups and database downloads too.

