sdxc

Type to search, or start from one of these:

[ Identity & security ]

Client addresses and networks

Read the caller's address once per request, key limits on its network, store it as text, admit only your own networks and refuse internal targets.

Last updated 2026-10-05

An IP address shows up in more places than it looks: the key of a rate limit, a column on a sign-in record, a field in a log line, the gate in front of an admin page, the host of a webhook URL someone typed. Each place gets it as text, and text can be "unknown", a comma-joined pair of headers, an IPv6 address written three different ways, or 0x7f.1. A key built from that text counts one client as many, and a check written against it lets an internal address through.

This guide parses the address once, at the edge of your app, and passes a checked value everywhere after. @sdxc/get-client-ip reads the connecting address Cloudflare vouches for and publishes it as ctx.ip. @sdxc/ip is the value it holds: an IP that knows its canonical spelling, the network it sits in and what the IANA registries say it is for, plus IP.Range for networks.

npm add @sdxc/ip @sdxc/get-client-ip @sdxc/rate-limit @sdxc/result \
	@sdxc/validate @sdxc/http remix

Publish the address once

Add the middleware to the router's global chain. It reads the CF-Connecting-IP header Cloudflare attaches to every request it routes, parses it, and publishes the result as ctx.ip before any route middleware or handler runs.

bootstrap/app.tsx
import type { Middleware } from "remix/router";

import getClientIP from "@sdxc/get-client-ip/middleware";
import { log } from "@sdxc/logger/middleware";
import { createRouter } from "remix/router";

import defaultHandler from "~/app/http/controllers/default-handler";
import routes from "~/routes/web";

import { logger } from "./logger";

export default function application() {
	let middleware: Middleware[] = [
		log(logger) as Middleware,
		getClientIP(),
		// …then cop(), formData() and a renderer
	];

	let router = createRouter({ middleware, defaultHandler });
	// router.map(…) for each route
	return router;
}

Importing the middleware module types ctx.ip as IP | null across your project, so a handler reads it like any other property. Code that holds a context without the property installed, such as a helper typed against a bare RequestContext, reads the same value with ctx.get(ClientIP), importing the ClientIP key from the same module; on a context the middleware never ran on, the key answers null.

Decide what null means

ctx.ip is null in two cases: the header is absent, which happens exactly when something other than Cloudflare's edge served the request (a local dev server, a test, another host), or the header is present and is not one address. Both mean the same thing to your app: nobody vouched for the caller's address. Pick the answer per use, by what an unknown caller should get:

  • A budget shares one bucket across every unknown caller, so it stays finite.

  • A gate refuses, because admitting an unknown caller is the same as having no gate.

  • A record stores null, so the column says "unknown" rather than a made-up value.

The sections below take each of those in turn.

Key a budget on the client's network

An IPv6 client is normally handed a whole /64, and its operating system rotates through addresses inside it on its own. A limit keyed on the full address gives that client a fresh budget with every rotation. ip.network({ v4: 32, v6: 64 }) answers the network the address sits in: the address itself for IPv4, its /64 for IPv6. Its canonical text is the key.

app/http/middleware/rate-limit.ts
import type { IP } from "@sdxc/ip";
import type { Middleware } from "remix/router";

import { KVAdapter } from "@sdxc/rate-limit";
import { rateLimit } from "@sdxc/rate-limit/middleware";
import { env } from "cloudflare:workers";

export const CLIENT_NETWORK: IP.Prefixes = { v4: 32, v6: 64 };

export function clientBudget(prefix: string, limit: number): Middleware {
	return rateLimit({
		adapter: new KVAdapter(env.RATE_LIMITS, { limit, window: "1 minute" }),
		prefix,
		key: (ctx) => ctx.ip?.network(CLIENT_NETWORK).toString() ?? "unknown",
	});
}

Every address in one /64 prints the same network, so 2001:DB8:1:2::7 and 2001:db8:1:2:0:0:0:9 spend the same budget. A /64 is the narrowest prefix that is reliably one subscriber. A provider that hands out a /56 or a /48 lets a client spread across several keys; a shorter IPv6 prefix in CLIENT_NETWORK, such as 56, caps it at the cost of grouping more neighbours behind one budget. Protect forms from bots and abuse puts this key in front of a sign-up form, with a CAPTCHA after it.

Pass an IP, not a string

A function that takes an IP cannot be handed "unknown" or a hostname, so the check happens once, where the value enters. Write your own services against the type:

app/services/sign-ins.ts
import type { Database } from "remix/data-table";

import { IP } from "@sdxc/ip";
import { isSuccess } from "@sdxc/result";

import { CLIENT_NETWORK } from "~/app/http/middleware/rate-limit";
import { SignIns } from "~/app/repositories/sign-ins";

export async function recordSignIn(db: Database, userId: string, ip: IP | null) {
	await SignIns.create(db, { userId, ip: ip?.toString() ?? null });
}

export async function isNewNetwork(db: Database, userId: string, ip: IP) {
	let last = await SignIns.latest(db, userId);
	if (!last || last.ip === null) return true;

	let stored = IP.parse(last.ip);
	if (!isSuccess(stored)) return true;

	let before = stored.data.network(CLIENT_NETWORK);
	return !before.equals(ip.network(CLIENT_NETWORK));
}

SignIns is your own repository over a table with a text ip column. toString() writes the canonical form, so equal addresses store equal strings and an index on the column finds them. Reading it back goes through IP.parse, which turns a row written by older code, or edited by hand, into a failure you handle instead of a value you trust.

Compare two addresses or two networks with equals. Two IPs parsed from the same text are two objects, and === compares objects by identity. The same goes for anything that crosses a structured clone, such as Durable Object storage or postMessage: the copy arrives without its methods, so store toString() and parse it on the other side.

A handler hands over what the middleware published:

app/http/controllers/sessions/create.ts
await recordSignIn(ctx.db, user.id, ctx.ip);
if (ctx.ip && (await isNewNetwork(ctx.db, user.id, ctx.ip))) {
	ctx.log.set({ sign_in: { new_network: true } });
}

Write it into the log

An IP serializes as its canonical text through toJSON, and toString() makes that explicit when you add it to the request's record:

app/http/controllers/sessions/create.ts
ctx.log.set({ client: { ip: ctx.ip?.toString() ?? null } });

One field on the record that is already open covers the whole request, so a query groups by client without each handler writing its own line. An IP address is personal data in many jurisdictions; decide how long your logs keep it before you add the field.

Admit only your own networks

An admin area that only staff on the office network or the VPN should reach is a list of ranges and a membership test. Keep the list as plain strings at module level, and parse inside the function: a Worker that does work in its global scope fails upload validation, and parsing a handful of ranges per request costs microseconds.

app/http/middleware/office-only.ts
import type { Middleware } from "remix/router";

import { forbidden } from "@sdxc/http/response/json";
import { IP } from "@sdxc/ip";
import { isSuccess } from "@sdxc/result";

export const OFFICE_NETWORKS = ["203.0.113.0/24", "2001:db8:42::/48"];

export function isOfficeAddress(ip: IP): boolean {
	return OFFICE_NETWORKS.some((text) => {
		let range = IP.Range.parse(text);
		return isSuccess(range) && range.data.contains(ip);
	});
}

export default function officeOnly(): Middleware {
	return (ctx, next) => {
		if (ctx.ip && isOfficeAddress(ctx.ip)) return next();
		return forbidden({ error: "This page is open to the office network." });
	};
}

Install officeOnly() in the middleware of the admin routes' router.map, after the global chain has published ctx.ip. A request with no usable address is refused with the rest.

IP.Range.parse refuses 203.0.113.1/24 with host-bits-set, because an address with bits past its prefix names a host where a network was meant. A typo in the list therefore fails to parse rather than silently matching a different network, and the test at the end of this guide asserts every entry parses. contains never matches across versions, so an IPv4 range admits no IPv6 client: list the office's IPv6 prefix too when it has one.

Refuse an internal address someone typed

A webhook target, an uptime probe or an avatar URL is a host chosen by someone else, and your Worker will connect to it. When that host is an address literal, @sdxc/ip classifies it before anything is stored:

app/http/controllers/webhooks/create.ts
import { created, unprocessableEntity } from "@sdxc/http/response/json";
import { IP } from "@sdxc/ip";
import { isFailure, isSuccess } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";

import { Webhooks } from "~/app/repositories/webhooks";
import routes from "~/routes/web";

const WEBHOOK = s.object({ url: s.string() });

export default createAction(routes.webhooks.create, async (ctx) => {
	let body = await validate(ctx.request, WEBHOOK);
	if (isFailure(body)) return unprocessableEntity({ issues: body.error.issues });

	let url = URL.parse(body.data.url);
	if (url === null) return unprocessableEntity({ error: "That is not a URL." });

	let literal = IP.parse(url.hostname);
	if (isSuccess(literal) && !literal.data.isPublic) {
		return unprocessableEntity({
			error: "That address is not on the public internet.",
			classification: literal.data.classification,
		});
	}

	let webhook = await Webhooks.save(ctx.db, { url: url.href });
	return created({ id: webhook.id });
});

Parse the URL first and classify its hostname. URL rewrites the octal, hex and bare-integer spellings of IPv4 (http://0x7f.1/, http://2130706433/) into dotted decimal, which is the only IPv4 form IP.parse accepts, and keeps the brackets on IPv6, which IP.parse reads. classification names why an address was refused (loopback, private, link-local, documentation and the rest), so the response can say which.

IPv6 can carry an IPv4 address inside it: IPv4-mapped (::ffff:10.0.0.1), NAT64 (64:ff9b::a00:1) and 6to4 addresses. classification and isPublic judge those by the IPv4 inside, so all three spellings of 10.0.0.1 are private, and embeddedIPv4 answers the inner address when you want to log or show it.

A name is the other half. hooks.example.com passes this check and can still resolve to 127.0.0.1, and a redirect can lead anywhere. Checking every address a name resolves to, on every hop, belongs in the fetch itself: Fetch URLs a stranger chose covers that.

Test with the header Cloudflare sends

In a test, no edge sits in front of the router, so ctx.ip is null until the request carries CF-Connecting-IP. Set the header to drive each branch, and leave it off to drive the unknown caller:

app/http/middleware/office-only.test.ts
import getClientIP from "@sdxc/get-client-ip/middleware";
import { IP } from "@sdxc/ip";
import { isSuccess } from "@sdxc/result";
import { createRouter } from "remix/router";
import { expect, test } from "vitest";

import officeOnly, { OFFICE_NETWORKS } from "./office-only";

function adminRouter() {
	let router = createRouter({ middleware: [getClientIP(), officeOnly()] });
	router.get("/admin", () => new Response("ok"));
	return router;
}

function visit(ip?: string) {
	let headers = new Headers();
	if (ip !== undefined) headers.set("CF-Connecting-IP", ip);
	return adminRouter().fetch(new Request("https://app.test/admin", { headers }));
}

test("an office address reaches the page", async () => {
	expect((await visit("203.0.113.40")).status).toBe(200);
	expect((await visit("2001:DB8:42::7")).status).toBe(200);
});

test("any other caller is refused", async () => {
	expect((await visit("198.51.100.7")).status).toBe(403);
	expect((await visit()).status).toBe(403);
	expect((await visit("203.0.113.40, 10.0.0.1")).status).toBe(403);
});

test("every office network parses", () => {
	for (let text of OFFICE_NETWORKS) {
		expect(isSuccess(IP.Range.parse(text))).toBe(true);
	}
});

The third request in the refusal test is what a header repeated across two lines reads as: a comma-joined string, which is not one address, so ctx.ip is null and the gate holds. Use the documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32) for test addresses; they are reserved for examples and never belong to a real client.

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.