IP allowlist
Server-enforced source-IP restrictions — per tenant and per Client Key. CIDR-capable, lockout-safe, complementary to CORS.
The IP allowlist restricts which source IPs can call Febasi Auth on behalf of a tenant. It is enforced server-side as part of the auth pipeline — before the route handler runs — so unlike CORS, it cannot be bypassed by a non-browser client.
CIDR matching (IPv4 and IPv6) is provided by ipaddr.js, with automatic
normalization of IPv4-mapped IPv6 addresses (::ffff:203.0.113.42 →
203.0.113.42) so dual-stack listening sockets do not break IPv4-only
entries.
CORS protects the browser from reading responses from disallowed origins; the IP allowlist protects the server from accepting requests from disallowed networks. Both can be configured. Most production tenants will want both: CORS to gate browser callers, IP allowlist as defense-in-depth for Client Keys.
When to reach for which
CORS — browser-only callers. Origin-based. Bypassable by non-browser clients (that's by design; CORS is a browser security feature).
IP allowlist — every caller, browser or not. IP-based. Bypassable only by IP spoofing, which is not feasible with TCP.
If you need to ensure a leaked Client Key cannot be used from outside your corporate network, that is an IP allowlist job, not a CORS job.
The two-tier model
| Tier | Stored on | Applies when |
|---|---|---|
| 1 | tenant.authConfig.ipAllowlist | The request has no Client Key, or the key did not set its own list. |
| 2 | client_keys.allowedIps | The request is authenticated by a Client Key that set its own list. |
Resolution order on every request:
- Client Key sets
allowedIps? Use it. - Otherwise, use the tenant's
authConfig.ipAllowlist.allowedIps. - Empty list / unset /
'*'→ no restriction; request passes.
A miss against a configured allowlist returns 403 IP_NOT_ALLOWED and
emits an audit event.
Tier 1 — Tenant IP allowlist
// authConfig.ipAllowlist
{
"allowedIps": ["203.0.113.0/24", "198.51.100.7", "2001:db8::/32"]
}Entries may be single IPs or CIDR ranges. Both IPv4 and IPv6 are supported. Maximum 1000 entries per tenant.
Configuring
PATCH /api/v1/tenants/:id/config
Authorization: Bearer <jwt-with-tenants:update>{
"ipAllowlist": {
"allowedIps": ["203.0.113.0/24"]
}
}'*' (literal string, not an array) is an explicit opt-out:
{ "ipAllowlist": { "allowedIps": "*" } }Semantically the same as an empty array. Use it when you want the intent to be unmistakable in audits — "this tenant chose to disable IP allowlisting."
Lockout protection
If the proposed list excludes the IP making the update, the server
rejects with 400 IP_LOCKOUT_PREVENTED and names the caller's IP. To
override (recovery scenarios), send ?force=true — it logs an
IP_ALLOWLIST_FORCE_UPDATE audit event so the override is auditable
after the fact.
Platform-level overrides (super-admin keys with admin:*) are exempt
from the lockout check so a tenant locked out can still be recovered
without a service restart.
Write-time validation
Every entry is validated through the CIDR parser before persisting. If any entry is malformed, the update is rejected with:
{
"success": false,
"code": "VALIDATION_ERROR",
"error": "Invalid IP allowlist entries",
"details": { "invalidEntries": ["10.0.0.0/99", "not-an-ip"] }
}Malformed entries fail at the write boundary, not silently at match
time. Inspect details.invalidEntries to know which ones to fix.
Tier 2 — Per-Client-Key IP allowlist
POST /api/v1/client-keys
Authorization: Bearer <jwt-with-client-keys:create>{
"name": "Acme cron worker",
"scopes": ["users:read"],
"tenantAccessLevel": "specific",
"allowedTenantIds": ["01HX0..."],
"allowedIps": ["52.10.0.0/16"]
}allowedIps on a key accepts the same '*' | string[] shape as the
tenant tier. Leaving it null (the default) inherits from the tenant.
When to set it
- Backend keys with known egress. Cloud workloads usually egress from a NAT or a known CIDR range — pin the key to that range so a leak is bounded by the network the attacker must operate from.
- Sensitive scopes. A key carrying
users:createoradmin:*is catastrophic if leaked. A tight IP allowlist is cheap insurance. - Belt-and-braces for publishable keys (future). When the publishable key type lands, a per-key IP allowlist will let tenants restrict signup traffic to their own data-center egress for the proxy variant, while still using origin allowlist for the browser-direct variant.
When not to set it
- Tenants whose callers have dynamic IPs with no CIDR you can pin. Examples: residential developers, freelancers on home internet. Setting an allowlist here breaks more than it protects.
Precedence
The Tier-2 list replaces the Tier-1 list when set — they do not merge. Same rule as CORS:
- A key with a stricter allowlist than the tenant stays strict regardless of tenant changes.
- A key with a more permissive allowlist is not silently narrowed by the tenant.
- To inherit "whatever the tenant currently allows," leave the key's
allowedIpsunset.
CIDR examples
| Use case | Entry |
|---|---|
| Single IPv4 | 203.0.113.42 |
| IPv4 /24 (256 addresses) | 203.0.113.0/24 |
| Single IPv6 | 2001:db8::1 |
| IPv6 /32 prefix | 2001:db8::/32 |
| AWS VPC CIDR (example) | 10.0.0.0/16 |
| Cloudflare egress (example) | 104.16.0.0/13 |
Always prefer CIDR over individual IPs for cloud workloads — egress IPs rotate.
Where the check fires
The IP allowlist is enforced at three distinct points in the request pipeline, all running before any route handler:
| Path | Where the check runs |
|---|---|
| Any authenticated route | Inside the auth middleware (authMiddleware for JWT, clientKeyAuthMiddleware for API keys) after the credential is validated. |
POST /api/v1/login | Inside AuthService.login() after the tenant is resolved from the body, before any bcrypt.compare work. Prevents credential probing from banned IPs. |
POST /api/v1/refresh | Inside AuthService.refreshAccessToken() after the refresh-token row is looked up (which yields the tenant), before issuing new tokens. |
POST /api/v1/logout | Exempt by design. Logout from an unallowed IP is benign — blocking it punishes legitimate users (e.g., a session whose origin network is no longer reachable). |
Public auth routes (/login, /refresh) do their own check because the
tenant context only becomes resolvable after body parsing — the auth
middlewares do not run on these routes.
Audit events
Every denial appends a row to auth_logs:
| Action | Emitted when |
|---|---|
IP_DENIED | A request was rejected because the source IP is not in the allowlist. |
IP_ALLOWLIST_FORCE_UPDATE | A tenant config or Client Key update used ?force=true to bypass the lockout check. High-severity — review periodically. |
Both events carry the failing IP, the responsible tenant, and (when
applicable) the key id in errorMessage. They sit alongside LOGIN,
LOGOUT, REFRESH_TOKEN, etc. in the audit log.
Trust proxy and source IP
Febasi Auth runs with trustProxy: true. The IP that the allowlist
checks is whatever X-Forwarded-For resolves to after proxy
normalization — your reverse proxy must control this header for
untrusted callers, or IP spoofing is trivial. Confirm with operations
that your edge proxy (Cloudflare, AWS ALB, nginx) strips or rewrites
X-Forwarded-For for clients that didn't come through the trusted
chain.
Cache and propagation
The resolver caches lookups in memory with a 60-second TTL. The cache is pre-warmed on service start so the first request after deploy does not pay the rebuild cost. Configuration changes propagate as follows:
PATCH /tenants/:id/configinvalidates the cache on the instance handling the request, so the change takes effect immediately there.- Client Key create / update / revoke invalidates the cache the same way.
- Multi-instance deployments rely on the 60-second TTL for peer instances until Redis pub/sub propagation lands.
Expect up to 60 seconds of lag between an allowlist change and its effect across all instances of a multi-replica deployment. Single-instance deployments see the change immediately.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
403 IP_NOT_ALLOWED | Source IP not in the configured allowlist. | Add the IP or CIDR range to authConfig.ipAllowlist or to the key's allowedIps. |
400 IP_LOCKOUT_PREVENTED on a config update | The new list excludes your current IP. | Add your IP to the list, or pass ?force=true (audit-logged). |
400 VALIDATION_ERROR with invalidEntries on save | One or more entries are not valid IPs or CIDR ranges. | Fix the malformed entries listed in details.invalidEntries. Parsing is strict — typos and out-of-range prefixes are rejected. |
IP_NOT_ALLOWED on /login even though the user JWT path works | /login checks against tenant.ipAllowlist; per-key inheritance does not apply because no key is in the request yet. | Ensure the source IP is in the tenant's allowlist, not just a key's. |
| Allowlist seems ignored | Cache lag (up to 60s on peer instances). | Wait one minute. If still ignored on the writing instance, verify hydration via the resolver stats. |
| Different result from different clients on the same network | Some traffic egresses from a different NAT. | Use the broader CIDR that covers both NATs, or add both /32 entries. |
Related
- CORS configuration — browser-side origin gate; complementary, not a substitute.
- Client Keys — where per-key
allowedIpsis set. - Audit logs —
IP_DENIEDevents are emitted alongsideLOGIN,LOGOUT, etc.