Security

Auth, API keys, rate limits, and the safe generative UI model.

Breeze treats the backend as a private service and checks every access at the layer that owns it. This page documents the guarantees, and the defaults you should harden before it faces anything but localhost.

The defaults are tuned for one person on one machine

They are not wrong, but they assume a trusted network. The production hardening section is the part to read before you expose this.

Authentication

NextAuth with the Credentials provider and JWT sessions.

  • Passwords are hashed with bcryptjs at cost factor 12. Signup requires a name, a valid email, and a password of at least 8 characters.
  • Emails are normalised to lowercase at signup and on lookup.
  • Session tokens are stateless JWTs signed with NEXTAUTH_SECRET. Nothing is stored in a session database.
  • Every protected API route calls getServerSession(authOptions) and returns 401 without a session.

Ownership checks

Conversations are scoped to the signed-in user, and every CRUD route enforces it.

SituationResponse
No session401
Another user's conversation403
Missing conversation404

Zod validates PATCH bodies with a strict schema that rejects unknown fields, and message deletion validates fromId as a Mongo ObjectId.

Service-to-service auth

The FastAPI backend is never exposed to the browser. Requests go through the Next.js API routes, which add the X-API-Key header. The backend compares it against API_KEY with a constant-time comparison and returns 401 on any mismatch.

EndpointProtected
/completionYes
/summarizeYes
/No, by design
/healthNo, by design

Rate limiting

Per client IP, via slowapi.

EndpointLimit
/completion10/minute
/summarize20/minute

Generative UI safety

The model emits data, never JSX

Nothing the model produces is ever evaluated.

  • Widget specs are zod-validated against a closed, compile-time whitelist in lib/genui/schema.ts.
  • An unknown widget type renders as collapsed JSON.
  • Component dispatch is an exhaustive, type-safe switch -- not a lookup keyed on a model-supplied string.
  • Chart colours come from a validated fixed palette, not from model output.

Full detail in the Generative UI guide.

Untrusted web content

Search results and fetched pages are attacker-controlled input: a page can carry text written specifically to be read by a model. Two surfaces, handled separately.

Prompt injection

Every retrieved byte passes one choke point, backend/evidence.py, so a defence added once covers Tavily, the keyless fallback and fetch_url alike.

Containment is structural first. Web text is only ever carried in a user turn -- never a system message, never a tool message -- fenced, numbered and length-capped, with a standing system-prompt rule that text inside the fence is data and never instructions.

On top of that, the sanitiser strips what would let text stop being text:

StrippedWhy
<|im_start|> and other control tokensWould open a forged turn inside the prompt
<think> / </think>Would misroute answer text into the reasoning panel
```breeze-ui fencesA page could otherwise inject a widget into an answer
The evidence fence itselfContent must not be able to close its own quoting
Zero-width and bidi charactersHide text from a human reviewing the page or log

The widget fence is the one specific to Breeze

A breeze-ui fence renders. Without neutralising it, any page the model reads could publish a chart, table or card into an assistant reply.

SSRF

fetch_url is the only place the backend requests an address a model chose, so backend/webfetch.py assumes every URL is hostile.

  • Schemes and ports: http(s) only, ports 80/443 only. No file://, and no port 6379 or 11434 -- the Redis or Ollama instance next to the process.
  • Credentials: refused in the URL, so nothing leaks to logs and http://expected.com@attacker.com cannot pass a human skim.
  • Addresses: every DNS answer must be globally routable, blocking loopback, RFC1918, link-local (169.254.169.254 is the cloud metadata endpoint), CGNAT, multicast and reserved space, in IPv4 and IPv6 including IPv4-mapped. Every answer, so a name resolving to both a public address and 127.0.0.1 is refused.
  • Redirects: followed by hand, max 3, each hop re-validated -- a public host answering 302 -> http://127.0.0.1:11434 is the standard way past a check that only inspects the URL it was handed.
  • Resources: 8s timeout, response capped while streaming (Content-Length is attacker-supplied), content-type allowlist.
  • No credential egress: no cookies, no auth headers, ever.

HTML is reduced to text by a stdlib parser that drops script/style, comments, and display:none / aria-hidden elements -- the standard hiding places for instructions invisible to a person checking the page.

Secrets

Keep these out of version control. The repo's .gitignore already excludes .env and .env.local.

SecretProtects
NEXTAUTH_SECRETSigns your JWT session tokens.
OLLAMA_API_KEY / API_KEYThe shared frontend-backend key.
TAVILY_API_KEYYour web search credential.
LANGFUSE_*Access to your traces.

Production hardening

Put the backend behind your reverse proxy

/completion should be reachable from the Next.js process only, not from the public internet. The API key is a second line of defence, not the first.

Forward the real client IP

Rate limits are keyed by IP. Behind a load balancer, every request looks like it came from the proxy, so the limit applies to the proxy as a whole rather than per user.

Add security headers

There is no headers() block in next.config.ts -- no CSP, no HSTS. Add one, or terminate on a platform that sets them for you.

Point Langfuse at a deployment you control

If you enable tracing, your prompts and completions go to whatever LANGFUSE_BASE_URL names. Confirm it is yours. See Langfuse.

Known trade-offs