apfel's HTTP server (--serve) runs on localhost by default and is designed for local development and on-device inference. This document explains the security settings and how to configure them for your specific use case.
The Origin HTTP header is the key. Browsers automatically attach it to cross-origin requests. Non-browser tools (curl, Python SDK, shell scripts) don't.
Browser on evil.com -> fetch("http://localhost:11434/v1/chat/completions")
^^ Browser adds: Origin: http://evil.com
^^ apfel sees foreign origin -> 403 Forbidden
curl http://localhost:11434/v1/chat/completions
^^ No Origin header sent
^^ apfel sees no Origin -> allowed (backward compatible)
This single check protects against browser-based attacks while keeping all non-browser workflows unchanged.
apfel --serveapfel server
├ endpoint: http://127.0.0.1:11434
├ cors: disabled
├ origin: localhost only (http://127.0.0.1, http://localhost, http://[::1])
├ token: none
└ ready
What works:
# curl - no Origin header, always works
curl http://localhost:11434/v1/models
# => 200 OK
# Python SDK - no Origin header, always works
python3 -c "
from openai import OpenAI
c = OpenAI(base_url='http://localhost:11434/v1', api_key='ignored')
print(c.models.list().data[0].id)
"
# => apple-foundationmodel
# Browser JavaScript from localhost - allowed
# fetch("http://localhost:11434/v1/models") from http://localhost:3000
# => 200 OK, Access-Control-Allow-Origin: http://localhost:3000What's blocked:
# Browser JavaScript from a foreign site
curl -H "Origin: http://evil.com" http://localhost:11434/v1/models
# => 403 Forbidden
# => {"error":{"message":"Origin 'http://evil.com' is not allowed.","type":"forbidden"}}
# Subdomain attacks (http://localhost.evil.com != http://localhost)
curl -H "Origin: http://localhost.evil.com" http://localhost:11434/v1/models
# => 403 ForbiddenWhat this means: Out of the box, your server is protected from cross-site attacks. curl, SDKs, and scripts work unchanged. Local browser apps can send requests and read simple GET responses. For full browser support (POST, custom headers), add --cors.
Enables full CORS support: the server responds to OPTIONS preflight requests with the necessary Access-Control-Allow-* headers so browsers can make POST requests and send custom headers (like Authorization).
apfel --serve --cors├ cors: enabled
├ origin: localhost only (http://127.0.0.1, http://localhost, http://[::1])
What changes:
# OPTIONS preflight now returns full CORS headers
curl -X OPTIONS -D - http://localhost:11434/v1/chat/completions -o /dev/null
# => 204 No Content
# => Access-Control-Allow-Origin: http://localhost:3000 (if Origin sent)
# => Access-Control-Allow-Methods: GET, POST, OPTIONS
# => Access-Control-Allow-Headers: Content-Type, Authorization
# => Access-Control-Max-Age: 86400
# Browser POST requests now work from localhost
# fetch("http://localhost:11434/v1/chat/completions", {
# method: "POST",
# headers: {"Content-Type": "application/json"},
# body: JSON.stringify({model: "apple-foundationmodel", messages: [...]})
# })
# => Works from http://localhost:* originsWhat stays the same:
# Foreign origins still blocked
curl -H "Origin: http://evil.com" http://localhost:11434/v1/models
# => 403 Forbidden (--cors does NOT disable origin checking)Key insight: --cors enables browser communication, but does NOT weaken the origin check. Foreign sites are still blocked.
When to use: Your local web app needs to make fetch() calls to apfel. Without --cors, browsers block POST requests and requests with custom headers like Authorization.
Add specific origins to the default localhost allowlist. This is additive - localhost origins are always included.
apfel --serve --cors --allowed-origins "http://myapp.local:8080"├ cors: enabled
├ origin: localhost only (http://127.0.0.1, http://localhost, http://[::1], http://myapp.local:8080)
What changes:
# Custom origin now allowed
curl -H "Origin: http://myapp.local:8080" http://localhost:11434/v1/models
# => 200 OK
# => Access-Control-Allow-Origin: http://myapp.local:8080
# => Vary: Origin
# Default localhost origins still work
curl -H "Origin: http://localhost:3000" http://localhost:11434/v1/models
# => 200 OK
# Other origins still blocked
curl -H "Origin: http://evil.com" http://localhost:11434/v1/models
# => 403 Forbidden
# No Origin header still works (curl, SDKs)
curl http://localhost:11434/v1/models
# => 200 OKMultiple origins:
apfel --serve --cors --allowed-origins "http://localhost:3000,http://localhost:5173"How matching works:
| Origin in request | Pattern in list | Match? | Why |
|---|---|---|---|
http://localhost |
http://localhost |
Yes | Exact match |
http://localhost:3000 |
http://localhost |
Yes | Port variant (default list matches all localhost ports) |
http://localhost:5173 |
http://localhost |
Yes | Port variant |
https://localhost |
http://localhost |
Yes | HTTPS variant |
http://localhost.evil.com |
http://localhost |
No | Subdomain attack - not a port suffix |
http://127.0.0.2 |
http://127.0.0.1 |
No | Different IP |
http://myapp.local:8080 |
http://myapp.local:8080 |
Yes | Exact match |
http://myapp.local:9090 |
http://myapp.local:8080 |
Yes | Port variant |
Disables the Origin header check entirely. Any origin is allowed.
apfel --serve --no-origin-check├ cors: disabled
├ origin: disabled (all origins allowed)
What changes:
# Foreign origins now allowed
curl -H "Origin: http://evil.com" http://localhost:11434/v1/models
# => 200 OK
# => Access-Control-Allow-Origin: *
# All requests get wildcard CORS header
curl -H "Origin: http://anything.com" http://localhost:11434/v1/models
# => 200 OK
# => Access-Control-Allow-Origin: *Important: When origin checking is disabled, the server automatically adds Access-Control-Allow-Origin: * to all responses so browsers can actually use the endpoint. However, without --cors, OPTIONS preflight requests don't include Allow-Methods/Allow-Headers, so browser POST requests may still fail.
For full browser access from any origin, use --footgun instead (which combines --no-origin-check with --cors).
When to use: Trusted networks where you know who's connecting, but you don't need full browser CORS support.
Adds a second layer of security: every request must include a Bearer token. Works independently of origin checking.
apfel --serve --token "my-secret-token"├ origin: localhost only (http://127.0.0.1, http://localhost, http://[::1])
├ token: required
What changes:
# Without token - 401 Unauthorized
curl http://localhost:11434/v1/models
# => 401 Unauthorized
# => WWW-Authenticate: Bearer
# => {"error":{"message":"Invalid or missing Bearer token.","type":"authentication_error"}}
# Wrong token - 401 Unauthorized
curl -H "Authorization: Bearer wrong-token" http://localhost:11434/v1/models
# => 401 Unauthorized
# Correct token - 200 OK
curl -H "Authorization: Bearer my-secret-token" http://localhost:11434/v1/models
# => 200 OK
# On loopback binds, /health remains public for monitoring convenience
curl http://localhost:11434/health
# => 200 OK (no token needed)
# Python SDK - pass token as api_key
python3 -c "
from openai import OpenAI
c = OpenAI(base_url='http://localhost:11434/v1', api_key='my-secret-token')
print(c.models.list().data[0].id)
"
# => apple-foundationmodelSecurity note: When using --token (not --token-auto), the secret is NOT printed in the startup banner. Only token: required is shown.
Health note: When you bind to a non-loopback address such as 0.0.0.0 and enable --token, /health now requires the same Bearer token by default. Use --public-health only if you intentionally want unauthenticated monitoring on that network-exposed bind.
Debug note: Request log endpoints (/v1/logs, /v1/logs/stats) are only exposed when the server starts with --debug. When they are enabled, they still follow the same origin and token checks as the rest of the API.
When to use: Shared machines, multi-user environments, or any setup where you want to control who can use the model.
Like --token but auto-generates a UUID and prints it on startup so you can copy it.
apfel --serve --token-auto├ token: required
├ token: E259FD6E-1220-49CA-95CE-66D14BB7FD4B
└ ready
The generated token is printed in the banner. Share it with authorized users or scripts:
# Use the printed token
curl -H "Authorization: Bearer E259FD6E-1220-49CA-95CE-66D14BB7FD4B" http://localhost:11434/v1/models
# => 200 OKSet the token via environment variable. Useful for scripts and systemd services.
export APFEL_TOKEN="my-secret-token"
apfel --serve
# Banner shows: token: required (secret not echoed)The --token flag overrides APFEL_TOKEN. The --token-auto flag overrides both (generates a new random one).
The shooting-yourself-in-the-foot-at-some-point-in-the-future option. Combines --no-origin-check and --cors to disable all security.
apfel --serve --footgun├ cors: enabled
├ origin: disabled (all origins allowed)
├ WARNING: --footgun mode - no origin check + CORS enabled
├ Any website can access this server and read responses!
└ ready
What this means:
# Any website can make requests
curl -H "Origin: http://evil.com" http://localhost:11434/v1/models
# => 200 OK
# => Access-Control-Allow-Origin: *
# Full CORS preflight works for any origin
curl -X OPTIONS -H "Origin: http://evil.com" http://localhost:11434/v1/chat/completions -D - -o /dev/null
# => 204 No Content
# => Access-Control-Allow-Origin: *
# => Access-Control-Allow-Methods: GET, POST, OPTIONS
# => Access-Control-Allow-Headers: Content-Type, AuthorizationEquivalent to: --no-origin-check --cors
When to use: Quick demos, testing, or environments where you explicitly want zero restrictions and understand the risk.
The middleware checks in this order. The first failing check stops the request:
Request arrives
|
v
1. Is it OPTIONS? --> Yes --> Return preflight (with CORS headers if --cors)
|
No
v
2. Origin check enabled? --> Yes --> Is Origin allowed?
| |
| No --> 403 Forbidden
|
v
3. Token required? --> Yes --> Is /health on loopback/public-health? --> Yes --> Skip token check
| |
| No --> Valid token?
| |
| No --> 401 Unauthorized
|
v
4. Debug endpoint gating (`/v1/logs*` requires `--debug`)
|
| No --> 404 Not Found
|
v
5. Route handler (your actual request)
|
v
6. Add CORS headers to response (if applicable)
|
v
Response sent
This means:
- Origin check runs before token check. A foreign origin gets 403 even with a valid token.
/healthstays public on loopback by default. On token-protected non-loopback binds, it requires auth unless--public-healthis set./v1/logsand/v1/logs/statsare debug-only. They return 404 unless--debugis enabled.- OPTIONS preflight skips both checks. Browsers need preflight to succeed before sending the real request.
Your React/Vite/Next.js dev server on localhost:3000 needs to call apfel:
apfel --serve --cors --allowed-origins "http://localhost:3000"Your JavaScript:
const response = await fetch("http://localhost:11434/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "apple-foundationmodel",
messages: [{ role: "user", content: "Hello!" }]
})
});
const data = await response.json();Just run the server. Nothing extra needed:
apfel --serve
# curl works as-is
curl -X POST http://localhost:11434/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"apple-foundationmodel","messages":[{"role":"user","content":"Hi"}]}'
# Python SDK works as-is
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ignored")Bind to all interfaces and add token auth:
apfel --serve --host 0.0.0.0 --token-auto
# Share the printed token with people on your networkOther machines connect with:
curl -H "Authorization: Bearer <token>" http://192.168.1.42:11434/v1/models
curl -H "Authorization: Bearer <token>" http://192.168.1.42:11434/healthIf you really need unauthenticated health probes on that network-exposed bind:
apfel --serve --host 0.0.0.0 --token-auto --public-healthapfel --serve --cors --allowed-origins "http://localhost:3000,http://localhost:5173,http://localhost:8080"apfel --serve --cors --allowed-origins "http://localhost:3000" --token "$(openssl rand -hex 16)"This gives you: origin restricted to one specific app + token auth required + CORS for that app only.
apfel --serve --footgun
# WARNING banner printed - you know what you're doingEvery combination explained:
| Flags | Origin check | CORS headers | Preflight | Who can connect | Who can read responses |
|---|---|---|---|---|---|
| (default) | localhost only | on allowed requests | 204, no CORS | curl, SDKs, localhost browsers | curl, SDKs, localhost (simple GET only) |
--cors |
localhost only | on allowed requests | 204 + full CORS | curl, SDKs, localhost browsers | curl, SDKs, localhost browsers (POST too) |
--no-origin-check |
disabled | * on all |
204, no full CORS | everyone | everyone (simple GET only) |
--footgun |
disabled | * on all |
204 + full CORS | everyone | everyone (POST too) |
--token X |
localhost only | on allowed requests | 204, no CORS | token holders only (loopback /health stays public) |
token holders with localhost origin |
--cors --token X |
localhost only | on allowed requests | 204 + full CORS | token holders from localhost | token holders from localhost browsers |
--cors --allowed-origins X |
custom list | on allowed requests | 204 + full CORS | curl, SDKs, listed origins | curl, SDKs, listed origin browsers |
--footgun --token X |
disabled | * on all |
204 + full CORS | token holders from anywhere | token holders from any browser |
Reading the table:
- "Who can connect" = whose requests get a 200 response
- "Who can read responses" = whose browser JavaScript can read the response body (requires CORS headers)
- "simple GET only" = browsers can read GET responses but POST requires full CORS preflight (
--cors)