@sdxc/get-client-ip
Read the client IP from a Cloudflare Workers request
- Used by
- auth-saas, blog, books
- Source
- packages/get-client-ip
Read the client IP from a Cloudflare Workers request.
A request that reaches a Worker has already crossed Cloudflare's network, and every proxy
along the way is another hop that could have rewritten the source address. Cloudflare
settles it by attaching
CF-Connecting-IP,
a header carrying the address the connection actually came from. This package reads that
header, so the one name worth remembering is the function rather than the header spelling.
Installation
npm add @sdxc/get-client-ip
The rate-limit pattern below answers with a status helper from
@sdxc/response, which you install only if
you want those helpers.
Usage
Read The Caller's Address
import { getClientIP } from "@sdxc/get-client-ip";
export function GET(request: Request) {
let ip = getClientIP(request);
return Response.json({ ip });
}
Handle A Request Without The Header
The header arrives on every request Cloudflare routes, so it is absent exactly when something else served the request — a local dev server, a test, another host. Name the fallback and the rest of the handler stops caring:
import { getClientIP } from "@sdxc/get-client-ip";
export function GET(request: Request) {
let ip = getClientIP(request) ?? "unknown";
return Response.json({ ip });
}
Pair It With Cloudflare's Geolocation
A Worker request also carries a cf object with the location Cloudflare resolved for that
same connection, so the address and where it came from read together:
import { getClientIP } from "@sdxc/get-client-ip";
export function GET(request: Request) {
return Response.json({
ip: getClientIP(request),
country: request.cf?.country,
city: request.cf?.city,
region: request.cf?.region,
});
}
request.cf is typed by
@cloudflare/workers-types,
listed in the types of a Workers tsconfig.json.
API
getClientIP(request: Request): string | null
Returns the value of the request's CF-Connecting-IP header, or null when the header is
absent. IPv4 and IPv6 both come back as the text Cloudflare sent, and a header repeated
across several lines reads as one comma-joined string, the way Headers.get reports any
repeated header.
getClientIP(request); // "203.0.113.42"
Longhand, this is request.headers.get("CF-Connecting-IP") — the value of the export is
that the header name is written once, in a place a typo shows up as a failing test rather
than as a null at runtime.
Pattern: Rate Limiting Per Client
The address is the bucket key, so a counter in a KV namespace gives one budget per caller per window:
import { getClientIP } from "@sdxc/get-client-ip";
import { ok, tooManyRequests } from "@sdxc/response";
/** How many requests one address may spend inside the window. */
const LIMIT = 100;
const WINDOW_SECONDS = 60;
export default {
async fetch(request: Request, env: { KV: KVNamespace }) {
let key = `rate-limit:${getClientIP(request) ?? "unknown"}`;
let spent = Number((await env.KV.get(key)) ?? "0");
if (spent >= LIMIT) return tooManyRequests({ error: "Rate limit exceeded" });
await env.KV.put(key, String(spent + 1), { expirationTtl: WINDOW_SECONDS });
return ok({ status: "up" });
},
};
Every request that arrives without the header shares the unknown bucket, which keeps the
budget finite for traffic that reached the Worker some other way.
Pattern: Attaching The Address To A Log Line
Logging the address turns a stack trace into something you can correlate across requests:
import { getClientIP } from "@sdxc/get-client-ip";
export async function GET(request: Request) {
let url = new URL(request.url);
console.log("request.received", {
ip: getClientIP(request),
path: url.pathname,
method: request.method,
});
return new Response("OK");
}
An IP address is personal data in many jurisdictions. Decide what retention applies before a log line like this outlives the request that produced it.