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:
- Allowlist wins: If any allowlist (global or per-key) exists, the request IP must be present in it
- Denylist overrides: If the IP appears in any denylist, the request is rejected regardless of allowlist membership
- 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.tsvalidates every request against configurable allowlists and denylists - Global policies are defined via
IP_ALLOWLISTandIP_DENYLISTenvironment variables, parsed at startup insrc/lib/ipUtils.ts - Per-API-key policies stored in the
api_keystable 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →