Febasidocs
Concepts

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

TierStored onApplies when
1tenant.authConfig.ipAllowlistThe request has no Client Key, or the key did not set its own list.
2client_keys.allowedIpsThe request is authenticated by a Client Key that set its own list.

Resolution order on every request:

  1. Client Key sets allowedIps? Use it.
  2. Otherwise, use the tenant's authConfig.ipAllowlist.allowedIps.
  3. 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:create or admin:* 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 allowedIps unset.

CIDR examples

Use caseEntry
Single IPv4203.0.113.42
IPv4 /24 (256 addresses)203.0.113.0/24
Single IPv62001:db8::1
IPv6 /32 prefix2001: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:

PathWhere the check runs
Any authenticated routeInside the auth middleware (authMiddleware for JWT, clientKeyAuthMiddleware for API keys) after the credential is validated.
POST /api/v1/loginInside AuthService.login() after the tenant is resolved from the body, before any bcrypt.compare work. Prevents credential probing from banned IPs.
POST /api/v1/refreshInside AuthService.refreshAccessToken() after the refresh-token row is looked up (which yields the tenant), before issuing new tokens.
POST /api/v1/logoutExempt 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:

ActionEmitted when
IP_DENIEDA request was rejected because the source IP is not in the allowlist.
IP_ALLOWLIST_FORCE_UPDATEA 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/config invalidates 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

SymptomLikely causeFix
403 IP_NOT_ALLOWEDSource 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 updateThe new list excludes your current IP.Add your IP to the list, or pass ?force=true (audit-logged).
400 VALIDATION_ERROR with invalidEntries on saveOne 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 ignoredCache 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 networkSome traffic egresses from a different NAT.Use the broader CIDR that covers both NATs, or add both /32 entries.
  • CORS configuration — browser-side origin gate; complementary, not a substitute.
  • Client Keys — where per-key allowedIps is set.
  • Audit logsIP_DENIED events are emitted alongside LOGIN, LOGOUT, etc.

On this page