IP Address Restriction Policy
Restrict which clients can reach a route based on their IP address. Configure an allow list to expose an endpoint only to a set of known networks, a deny list to shut out abusive traffic, or both. Entries can be individual IP addresses or CIDR ranges, in IPv4 or IPv6.
With this policy, you'll benefit from:
- Private APIs on a Public Gateway: Limit admin, partner, or internal endpoints to your office, VPN, or data center ranges
- Immediate Blocking: Drop traffic from a bad actor's address or subnet without a deploy of your backend
- Per-Route Control: Apply different lists to different routes, so a public endpoint and a restricted one can live in the same API
- IPv4 and IPv6: Single addresses and CIDR ranges in both families, with IPv4-mapped IPv6 addresses handled automatically
- Fail-Closed Defaults: Requests whose address cannot be determined are rejected when an allow list is set
Configuration
The configuration shows how to configure the policy in the 'policies.json' document.
Code
Policy Configuration
name<string>- The name of your policy instance. This is used as a reference in your routes.policyType<string>- The identifier of the policy. This is used by the Zuplo UI. Value should beip-address-restriction-inbound.handler.export<string>- The name of the exported type. Value should beIpAddressRestrictionInboundPolicy.handler.module<string>- The module containing the policy. Value should be$import(@zuplo/runtime).handler.options<object>- The options for this policy. See Policy Options below.
Policy Options
The options for this policy are specified below. All properties are optional unless specifically marked as required.
allow<string[]>- The IP addresses and CIDR ranges that are allowed to call the route. When set, requests from every other address are rejected with a403 Forbidden.deny<string[]>- The IP addresses and CIDR ranges that are rejected with a403 Forbidden. Evaluated beforeallow, so an address matching both lists is rejected.clientIpHeader<string>- Read the client IP address from this header instead of the standard forwarding headers (x-real-ip,true-client-ip,cf-connecting-ip, then the first entry ofx-forwarded-for). Only set this when a proxy in front of the gateway reports the client address in a non-standard header.allowUnknownIpAddress<boolean>- Allow requests whose client IP address cannot be determined. By default these requests are rejected when anallowlist is set, because an unknown address cannot be on it. Defaults tofalse.
Using the Policy
This policy compares the client's IP address against an allow list, a deny list,
or both, and rejects non-matching requests with a 403 Forbidden. Entries are
individual IP addresses or CIDR ranges, in IPv4 or IPv6.
How It Works
For each request the policy:
- Determines the client IP address — the same one reported to your code as
context.incomingRequestProperties.ip— or reads it fromclientIpHeaderwhen that option is set - Rejects the request if the address matches any
denyentry - Rejects the request if an
allowlist is set and the address matches none of its entries - Otherwise passes the request to the next policy or the handler
deny is evaluated first, so an address covered by both lists is rejected.
At least one of allow or deny must be set — a policy with neither would let
every request through, which is never what was intended.
Policy Configuration
Allow only two networks and a single address:
Code
Block a single abusive network while leaving the route otherwise public:
Code
Writing Entries
- An entry without a prefix is a single address:
203.0.113.42is the same as203.0.113.42/32, and2001:db8::1the same as2001:db8::1/128. - Host bits beyond the prefix are ignored, so
10.1.2.3/8and10.0.0.0/8mean the same range. - IPv4-mapped IPv6 addresses are matched as the IPv4 address they map to. A
client that a dual-stack proxy reports as
::ffff:198.51.100.7matches an198.51.100.0/24entry. - An IPv4 address never matches an IPv6 range, and vice versa. To cover a client reachable over both, list a range for each family.
- Addresses with leading zeros in an octet (
010.0.0.1) are rejected, because different tools read them as different addresses.
Entries are validated when the policy is created, so a malformed address or prefix fails your deployment rather than rejecting live traffic. Each list can also be sourced from environment variables per entry:
Code
Usage Example
Restrict an admin route while leaving the rest of the API public:
Code
Put this policy first in the inbound list so restricted addresses are rejected before any work — authentication, rate limiting, upstream token fetches — is done on their behalf.
Where the Default Address Comes From
By default the policy uses the address the gateway itself resolved — the same
value your code sees as context.incomingRequestProperties.ip. That comes from
exactly one header, chosen by the platform running the gateway
(true-client-ip on Cloudflare, x-real-ip elsewhere), and it is resolved from
the request as it originally arrived.
Two consequences worth knowing:
- There is no fallback to a second header. If the trusted header is absent,
the address is reported as unknown rather than being guessed from
x-forwarded-foror another header. Every available fallback is one a caller can write, so a fallback would let anyone satisfy an allow list by sending a header. - Header stripping does not affect it. The gateway removes
x-real-ipand thecf-*headers from the request before policies run (from compatibility date 2024-01-15), but the default path reads the original request, so this makes no difference to it. It does affectclientIpHeader— see below.
During local development there is no edge to set the header and the caller
really is the loopback interface, so the address is reported as 127.0.0.1. An
allow list that does not cover 127.0.0.1 will reject your local requests — add
it to the list to test locally:
Code
Note that allowUnknownIpAddress does not help here. Locally the address is
known — it is 127.0.0.1 — and that option only applies when no address could
be determined at all.
Requests With No Client IP Address
When no client IP address can be determined, the request is rejected if an
allow list is set, since an unknown address cannot be on it. With only a
deny list the request continues, because there is nothing for it to match.
Set allowUnknownIpAddress to true to let these requests through even with an
allow list. This is most useful when you run your own proxy and cannot
guarantee it sets the trusted header on every request.
Custom Client IP Header
If a proxy in front of the gateway reports the client address in a header other
than the standard ones, name it with clientIpHeader:
Code
Only the named header is read when this is set. Prefer a header your proxy sets to a single address.
Unlike the default path, this option reads the request as policies see it, after
the gateway has stripped x-real-ip and the cf-* headers (from compatibility
date 2024-01-15). Naming one of those here resolves nothing, and with an
allow list that rejects every request. Use a dedicated header your proxy sets,
such as x-client-ip.
If the header holds a comma-separated list, the last entry is used, because proxies append and so the nearest proxy's entry is the rightmost one — everything to its left is whatever the caller sent. For a single-value header this makes no difference. This assumes one appending proxy in front of the gateway; if you have more than one, use a dedicated single-value header, since the trustworthy entry is then further left and its position depends on how many hops you control.
Security Considerations
This policy is only as trustworthy as the header it reads the client address from. A header is a security control only if the proxy in front of your gateway replaces any client-supplied value rather than appending to it.
The default path is not configurable for this reason: it reads a single header designated by the platform running the gateway, and never falls back to another one. On Zuplo's managed edge that header is set by the edge on every request and a caller cannot override it.
Two specifics worth knowing if you self-host or run a CDN in front of Zuplo:
true-client-ipis spoofable unless your own edge sets it. Cloudflare documents this directly: in a stacked-CDN setup, if you do not add the header yourself, "its value can be spoofed to any value".x-forwarded-foris a list, and the untrustworthy end comes first. When a request already carries anX-Forwarded-Forheader, Cloudflare appends to it rather than replacing it, and most proxies behave the same way. The entry your own proxy added is therefore at the end of the list, while the entries at the front are whatever the caller chose to send. The policy never reads this header on the default path — but if you pointclientIpHeaderat it, this is why only the last entry is used.
If a proxy you control reports the client address in a dedicated header that it
always overwrites, name that header with clientIpHeader. That is the
configuration to prefer whenever you have it, because it removes the ambiguity
entirely.
- Rejected requests get a
403 Forbiddenwith no detail about the address seen or the rule matched. The address and the matching entry are written to the request log at debug level instead, so an unauthorized caller is not told what the gateway sees or that it is filtering by address at all. - An IP address identifies a network path, not a caller. Combine this policy with an authentication policy rather than using it as your only control — addresses within an allowed range can be shared, reassigned, or spoofed upstream of your gateway.
- Prefer the narrowest ranges that work. A
/8allow entry covers 16 million addresses.
Read more about how policies work