How OmniRoute Enforces IP-Based Security Policies with Allowlist and Denylist Filtering

OmniRoute enforces IP-based security policies through a layered middleware that checks client IP addresses against global environment-based lists and per-API-key database configurations, rejecting requests with 403 Forbidden when policies are violated.

The OmniRoute routing service implements a comprehensive IP filter system that operates early in the request-handling pipeline. This mechanism protects HTTP endpoints by validating client IP addresses against configurable allowlists and denylists, applying a fail-closed security model that denies access by default unless explicitly permitted.

How the IP Filter Middleware Works

The IP filter implementation in src/middleware/ipFilter.ts processes every incoming request through a standardized validation flow. Understanding this flow helps operators configure policies correctly and debug access issues.

IP Address Extraction and Normalization

The middleware first extracts the client IP from req.headers["x-forwarded-for"], taking the first entry in the chain, and falls back to req.socket.remoteAddress when the header is absent.

IPv6 normalization occurs in src/lib/ipUtils.ts, which removes prefixes like ::ffff: to ensure consistent string matching regardless of address format.

// Simplified extraction pattern from src/lib/ipUtils.ts
function normalizeIP(ip: string): string {
  // Strip IPv4-mapped IPv6 prefix
  if (ip.startsWith('::ffff:')) {
    return ip.substring(7);
  }
  return ip;
}

Two-Level Policy Architecture

OmniRoute applies IP policies at two distinct levels, with per-API-key settings overriding global defaults:

Level Configuration Source Storage Format
Global Environment variables IP_ALLOWLIST, IP_DENYLIST Comma-separated string → Set<string>
Per-API-Key api_keys table columns ip_allowlist, ip_denylist JSON array → Set<string>

The global lists are parsed once at startup by src/lib/ipUtils.ts, while per-key lists are fetched dynamically from src/lib/db/apiKeys.ts after API key resolution.

Allowlist and Denylist Evaluation Logic

The IP filter enforces security policies through a strict priority-based decision engine with three core rules:

  1. Allowlist wins: If any allowlist (global or per-key) exists, the request IP must be present in it
  2. Denylist overrides: If the IP appears in any denylist, the request is rejected regardless of allowlist membership
  3. Fail-closed default: With no explicit allowlist, all IPs are rejected

This design ensures that accidental misconfiguration results in blocked access rather than exposed endpoints.

Environment Variable Configuration

Set global policies before server startup:


# Allow only specific IPs

export IP_ALLOWLIST="203.0.113.10,198.51.100.22"

# Block specific IPs even if in allowlist

export IP_DENYLIST="198.51.100.22"

Per-API-Key Policy Assignment

Configure granular access through database operations:

-- Create API key with restricted IP access
INSERT INTO api_keys (key_id, secret, ip_allowlist)
VALUES ('internal-service', 's3cr3t', '["10.0.0.0/8","172.16.0.0/12"]');

-- Key with both allow and deny restrictions
INSERT INTO api_keys (key_id, secret, ip_allowlist, ip_denylist)
VALUES (
  'partner-api',
  'p4rtn3r',
  '["203.0.113.0/24"]',
  '["203.0.113.15","203.0.113.16"]'
);

Integration with the Authorization Pipeline

The IP filter runs before the main authentication and authorization pipeline defined in src/server/authz/pipeline.ts. This ordering prevents credential validation overhead for requests that would be rejected on network grounds alone.

// Typical middleware stack configuration
import { ipFilter } from '@omniroute/middleware/ipFilter';
import { authPipeline } from '@omniroute/server/authz/pipeline';

app.use(ipFilter);      // ← Network-level security first
app.use(authPipeline);  // ← Credential validation second

When a request fails IP validation, the middleware immediately returns:

HTTP/1.1 403 Forbidden
Content-Type: application/json

{"error": "IP address not permitted"}

Runtime Policy Modification

For dynamic scenarios, src/lib/ipUtils.ts exports functions to update global policies without restart:

import { setGlobalIpAllowlist, setGlobalIpDenylist } from '@omniroute/lib/ipUtils';

// Update allowlist programmatically
setGlobalIpAllowlist(['10.0.0.1', '10.0.0.2']);

// Add emergency block
setGlobalIpDenylist(['192.0.2.100']);

These functions modify the in-memory Set<string> structures used by active middleware instances.

Parallel CORS Implementation

The IP filter pattern mirrors src/middleware/cors.ts, which implements origin-based allowlist/denylist filtering. Both middlewares share:

  • Normalized header extraction
  • Set-based membership testing
  • Identical precedence rules (allowlist required, denylist overrides)
  • Fail-closed defaults

This consistency across network-level (ipFilter) and application-level (cors) security controls reduces operational complexity.

Summary

  • OmniRoute's IP filter middleware in src/middleware/ipFilter.ts validates every request against configurable allowlists and denylists
  • Global policies are defined via IP_ALLOWLIST and IP_DENYLIST environment variables, parsed at startup in src/lib/ipUtils.ts
  • Per-API-key policies stored in the api_keys table override global settings for granular access control
  • The denylist takes precedence over the allowlist, and the fail-closed design rejects requests by default
  • Failed validation returns 403 Forbidden immediately, before authentication processing

Frequently Asked Questions

How does OmniRoute handle IP addresses behind load balancers?

OmniRoute prioritizes the X-Forwarded-For header over req.socket.remoteAddress, extracting the first (leftmost) entry in the proxy chain. Operators should ensure this header is sanitized by their edge infrastructure to prevent spoofing. The src/lib/ipUtils.ts module handles IPv6 normalization and format standardization.

Can I use CIDR notation in allowlists and denylists?

The base implementation performs exact string matching. For CIDR support, extend src/lib/ipUtils.ts with a range-checking utility before the middleware invokes Set.prototype.has(). The database schema stores lists as JSON arrays, allowing arbitrary string formats.

What happens if both global and per-key allowlists exist?

Per-key configurations override global settings entirely. When a valid API key is resolved, the middleware discards global lists and evaluates only the key-specific ip_allowlist and ip_denylist. To combine policies, replicate global entries in the per-key configuration.

Why does my request fail with 403 even when the IP is in the allowlist?

Check for denylist precedence: an IP present in both lists is rejected. Also verify that X-Forwarded-For extraction isn't capturing an intermediary proxy address rather than the original client. Enable debug logging in src/middleware/ipFilter.ts to trace the normalized IP value being evaluated.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →