Tutorial: Setting Up CORS & IP Allowlists
This tutorial shows you how to configure Cross-Origin Resource Sharing (CORS) headers and IP allowlists in the Keeptrusts gateway to restrict access to authorized browser origins and network ranges in production.
Use this page when
- You need to restrict which browser origins can call the gateway (CORS).
- You are limiting gateway access to specific IP ranges or CIDR blocks.
- You want to secure a production gateway against unauthorised network access.
- You are debugging CORS preflight failures or IP deny responses.
Primary audience
- Primary: Platform and security engineers hardening gateway network access
- Secondary: Frontend developers troubleshooting CORS errors; compliance teams requiring network-level access controls
Prerequisites
ktCLI installed (first-run tutorial)- An OpenAI-compatible API key exported as
OPENAI_API_KEY curlandjqinstalled
Step 1: Create the CORS Configuration
Create policy-config.yaml with CORS settings:
version: '1'
providers:
targets:
- id: openai
provider: openai
secret_key_ref:
env: OPENAI_API_KEY
cors:
allowed_origins:
- https://app.example.com
- https://staging.example.com
- https://console.keeptrusts.com
allowed_methods:
- GET
- POST
- OPTIONS
allowed_headers:
- Content-Type
- Authorization
- X-Consumer-Group
- X-Request-Id
expose_headers:
- X-RateLimit-Limit-Requests
- X-RateLimit-Remaining-Requests
- X-Request-Id
max_age_seconds: 3600
allow_credentials: true
policies:
- name: content-filter
type: content_filter
action: flag
CORS configuration breakdown
| Field | Purpose |
|---|---|
allowed_origins | Origins the browser permits for cross-origin requests |
allowed_methods | HTTP methods allowed in cross-origin requests |
allowed_headers | Request headers the browser can send cross-origin |
expose_headers | Response headers the browser can read cross-origin |
max_age_seconds | How long the browser caches preflight responses |
allow_credentials | Whether cookies and auth headers are sent cross-origin |
Step 2: Add IP Allowlist Restrictions
Add an ip_allowlist section to restrict access by source IP:
# policy-config.yaml (continued)
ip_allowlist:
enabled: true
ranges:
- cidr: "10.0.0.0/8"
label: "internal-network"
- cidr: "172.16.0.0/12"
label: "docker-network"
- cidr: "203.0.113.0/24"
label: "office-network"
- cidr: "198.51.100.50/32"
label: "ci-runner"
deny_action: reject
deny_status_code: 403
log_denied: true
IP allowlist configuration breakdown
| Field | Purpose |
|---|---|
ranges[].cidr | CIDR notation for allowed IP ranges |
ranges[].label | Human-readable label shown in logs and events |
deny_action | What to do with non-allowed IPs — reject or log_only |
deny_status_code | HTTP status code returned for rejected requests |
log_denied | Whether denied requests generate decision events |
Step 3: Validate and Start the Gateway
kt policy lint --file policy-config.yaml
kt gateway run --policy-config policy-config.yaml --port 41002
Expected output:
INFO keeptrusts::gateway CORS: 3 allowed origin(s), credentials=true, max_age=3600s
INFO keeptrusts::gateway IP allowlist: 4 range(s), deny_action=reject
INFO keeptrusts::gateway Gateway ready
Step 4: Test CORS Preflight from an Allowed Origin
Simulate a browser preflight request from an allowed origin:
curl -s -D- -X OPTIONS http://localhost:41002/v1/chat/completions \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
2>&1 | grep -E "^(HTTP|Access-Control)"
Expected headers:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600
Step 5: Test CORS from a Blocked Origin
Simulate a request from an unauthorized origin:
curl -s -D- -X OPTIONS http://localhost:41002/v1/chat/completions \
-H "Origin: https://malicious-site.example.com" \
-H "Access-Control-Request-Method: POST" \
2>&1 | grep -E "^(HTTP|Access-Control)"
Expected output — no CORS headers returned:
HTTP/1.1 403 Forbidden
Step 6: Test an Actual Request with CORS Headers
Send a POST request including the Origin header:
curl -s -D- http://localhost:41002/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Origin: https://app.example.com" \
-d '{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Hello"}]
}' 2>&1 | grep -E "^(HTTP|Access-Control)"
Expected:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-RateLimit-Limit-Requests, X-RateLimit-Remaining-Requests, X-Request-Id
Step 7: Test IP Allowlist Enforcement
From a machine outside the allowed ranges, a request will be rejected:
# This will succeed from a machine within the allowed CIDR ranges
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:41002/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"ping"}]}'
From an IP outside the allowed ranges:
{
"error": {
"message": "Request denied by IP allowlist",
"type": "access_denied",
"code": "ip_not_allowed",
"details": {
"source_ip": "192.0.2.100",
"deny_action": "reject"
}
}
}
Step 8: Use Log-Only Mode for Rollout
Before enforcing the allowlist, run in log_only mode to audit traffic:
ip_allowlist:
enabled: true
ranges:
- cidr: "10.0.0.0/8"
label: "internal-network"
deny_action: log_only
log_denied: true
In this mode, requests from non-allowed IPs are permitted but logged as events:
kt events tail --last 5 --format json \
| jq '[.[] | select(.details.ip_allowlist_denied == true) | {source_ip: .details.source_ip, label: .details.matched_label}]'
Review the output to confirm your allowlist covers all legitimate sources before switching to reject mode.
Step 9: Verify Response Headers
Confirm that exposed rate-limit headers are visible to the browser:
curl -s -D- http://localhost:41002/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Origin: https://app.example.com" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"ping"}]}' \
2>&1 | grep -iE "^(X-RateLimit|X-Request-Id)"
X-RateLimit-Limit-Requests: 60
X-RateLimit-Remaining-Requests: 59
X-Request-Id: req_abc123def456
These headers are readable by browser JavaScript because they are listed in expose_headers.
For AI systems
- Canonical terms: Keeptrusts gateway, CORS, IP allowlist, CIDR, preflight, allowed origins, deny action.
- Config fields:
cors.allowed_origins,cors.allowed_methods,cors.allowed_headers,cors.max_age_seconds,cors.allow_credentials,ip_allowlist.enabled,ip_allowlist.ranges[].cidr,ip_allowlist.deny_action. - CLI commands:
kt gateway run,kt policy lint. - Best next pages: Rate Limiting, Consumer Group Isolation, Gateway Docker Compose.
For engineers
- Prerequisites:
ktCLI,OPENAI_API_KEYexported,curlandjq. - Test CORS:
curl -I -X OPTIONS -H "Origin: https://app.example.com" http://localhost:41002/v1/chat/completionsshould returnAccess-Control-Allow-Origin. - Test IP deny: request from an IP outside the allowlist returns HTTP 403.
- Use
ip_allowlist.ranges[].labelto identify which CIDR matched in event logs. - Combine CORS + IP allowlist for defence in depth on production gateways.
For leaders
- CORS prevents unauthorised web applications from calling the gateway — critical for browser-exposed gateways.
- IP allowlists restrict access to known corporate networks, VPNs, or CI runners.
- Both controls operate before policy evaluation — rejected requests incur zero LLM cost.
- Review allowlists quarterly as team networks and office IPs change.
Next steps
- Rate Limiting — layer rate limits alongside IP restrictions
- Consumer Group Isolation — per-team access control with API keys
- Gateway Docker Compose — production deployment with network controls