Security (Mode B)¶
What you'll learn
How to harden the Mode B host (FastAPI server + WebSocket/SSE): require authentication on every connection, restrict origins (CORS), and verify JWTs on the server. The static modes (A/WASM and C/transpile) are just CDN-served bundles — there is no server to protect.
By default create_app(state_factory, view) is open: any client connects.
For production, pass a SecurityConfig.
An open host says so in the log
With no SecurityConfig the server logs a WARNING naming exactly what is
off — no auth, no origin allowlist (so any site can open a WebSocket to
your host) and no limits. That is the right default for tempestweb dev and
the wrong one to reach production unnoticed.
from tempestweb.server import create_app, SecurityConfig, token_authenticator
app = create_app(
make_state,
view,
security=SecurityConfig(
authenticate=token_authenticator("my-secret"), # S0 — auth gate
allowed_origins=["https://app.example.com"], # S1 — origin allowlist
),
)
S0 — authentication gate¶
authenticate runs on every connection (WebSocket upgrade and SSE requests)
before a session is created. A falsy return — or a raised error — rejects the
connection (WS closes with 1008; SSE returns 401). It may be sync or async.
It receives a Credentials:
| Field | Source |
|---|---|
token |
Authorization: Bearer <t> or ?token=<t> |
origin |
the Origin header |
headers |
request headers (lower-cased keys) |
query |
query parameters |
client_ip |
the peer address — or X-Forwarded-For, if trusted_proxies allows |
Two ready-made builders:
token_authenticator(secret)— a shared secret (theX-Tokenconvention), compared in constant time. An empty secret disables the gate (dev only).jwt_authenticator(key, ...)— accepts a valid, unexpired Bearer JWT (see S3).
Or write your own:
S1 — origin allowlist (CORS)¶
allowed_origins installs CORSMiddleware (HTTP/SSE surface) and checks the
Origin header on the WebSocket upgrade — which browser CORS does not guard.
allowed_origins=["https://app.example.com"]— only that origin connects.allowed_origins=["*"]— any origin (wildcard; skips the WS check).- Absent (
None) — no origin restriction.
WebSockets ignore CORS
Browsers don't apply CORS to WebSockets. The Origin check on the upgrade is
the only defense against a third-party site opening a WS to your server — so
it is done explicitly here.
S3 — server-side JWT verification¶
verify_jwt(token, key) validates the signature and expiry and returns the
claims — unlike observability.auth.decode_jwt, which only reads claims
(client-side).
The exp claim is required: PyJWT only checks an expiry that is present, so
a token minted without one would be accepted forever — which is not what
"validates the expiry" can mean. For a token whose lifetime something else
bounds, pass require_expiry=False (to verify_jwt and jwt_authenticator).
from tempestweb.server import verify_jwt, jwt_authenticator
claims = verify_jwt(token, KEY, algorithms=("HS256",), audience="my-app")
app = create_app(make_state, view, security=SecurityConfig(
authenticate=jwt_authenticator(KEY, audience="my-app"),
))
Requires the [auth] extra
verify_jwt uses PyJWT (tempest-fastapi-sdk[auth] / pip install pyjwt).
Without it, verify_jwt raises RuntimeError and jwt_authenticator rejects
the connection — it never silently accepts.
S2 — limits / anti-DoS¶
SecurityConfig(
max_connections=500, # cap on concurrent WS+SSE sessions
max_message_bytes=65536, # reject an SSE POST larger than this (413)
max_connections_per_minute=60, # per-IP connection flood (1013/429)
max_events_per_minute=600, # per-IP envelope flood (1013/429)
trusted_proxies=["10.0.0.1"], # whose X-Forwarded-For may be read
)
max_connections— a connection over the cap is refused (WS close1013; SSE503). The counter decrements when the session ends.max_message_bytes— aPOST /sse/{id}with a larger body returns413. The cap is enforced while the body is read, not fromContent-Lengthalone — a chunked POST declares no length and used to sail straight past it.max_connections_per_minute— a rolling 60s per-IP window; a flood is refused (WS1013/ SSE429). The address is the socket's peer, unlesstrusted_proxiessays otherwise (below).trusted_proxies— which peers'X-Forwarded-Formay be believed.None(the default) ignores the header;["*"]trusts any peer; a list of addresses trusts only those.max_events_per_minute— the same window, counting inbound envelopes (clicks, input,native_result) across both legs: aPOST /sse/{id}over budget answers429, and a WebSocket frame over budget closes the socket with1013. A separate knob because the magnitudes differ: one connection per client, but one envelope per interaction. Size it above your app's legitimate peak — without it, an already-accepted connection sends without limit.
X-Forwarded-For is client data
A per-IP limit only means anything if the IP is not chosen by whoever is
being limited. The header is sent by the client: believing it unconditionally
makes every request look like a fresh client and the limit never fires —
against a cap of 3 connections/minute, 8 connections carrying a forged
X-Forwarded-For per request all got through.
With trusted_proxies the header is read right to left: a proxy appends
the address it saw, so the right-most hop that is not a declared proxy is the
furthest one this deployment can vouch for; anything the client prepended
sits to the left and is ignored.
SSE sessions: the session in the URL authorizes nothing¶
The SSE leg splits into GET /sse?session=<id> (the stream) and
POST /sse/<id> (events), and the client picks the id. It names the session;
it does not prove who may use it. Anyone who merely learned the id would read
the victim's patch stream — which is the rendered state of her screen — and post
events into her session.
So the GET that materializes a session records a fingerprint of its
opener — the auth token when the host authenticates, the client address when it
does not — and every later GET/POST for that id must match it, or it is
answered 403.
- Reopening the stream is a takeover: the newest stream owns the session, and the one it replaced can no longer tear it down. That is the ordinary reconnect: the network drops, the client reconnects, and only then does the old response finish unwinding on the server.
- A gap in the replay becomes a resync: if
Last-Event-IDpoints at a tick the buffer has evicted, the server sends the whole scene (one root replace) before resuming, instead of index-relative patches that no longer fit.
Pick an unguessable session
Ownership protects the content, but the id still travels in a URL (logs,
referer, history). Generate it with crypto.randomUUID() — never a counter
or the user's id.
Dead + idle connections
A dead/half-open WS is already reaped by uvicorn's ping (~20–40s) — no
app idle-timeout needed. An active idle-timeout would also disconnect
legitimately-idle users, so it is not enforced — use max_connections +
rate limiting + the reverse proxy's limit_req for defense in depth.
concurrent_dispatch=True changes what max_events_per_minute protects
By default a session dispatches one event at a time, so a client flooding envelopes only queues work up — the queue grows, but there is always exactly one handler running.
With create_app(..., concurrent_dispatch=True)
every accepted envelope becomes its own task. The queue no longer holds
the flood back: what limits how many tasks one connection can open becomes
max_events_per_minute and nothing else.
If you turn the option on, set that cap explicitly. It defaults to
None, and None with concurrent_dispatch=True means unbounded task
fan-out per connection.
S6 — security headers¶
SecurityConfig(
security_headers=True, # nosniff + Referrer-Policy + X-Frame-Options: DENY
hsts=True, # Strict-Transport-Security (HTTPS only)
content_security_policy="default-src 'self'", # optional, app-specific
)
A middleware adds the headers to every HTTP response.
CSP and the shell
The static-mode index.html uses inline <script type="module">, so a strict
CSP needs a nonce/hash you supply in content_security_policy. That's why
CSP is an explicit opt-in, not a default.
XSS: safe by construction
The JS client never injects HTML — the patcher uses textContent and
setAttribute (never innerHTML). Dynamic content with </>/& renders
as text, not markup. Audit: zero HTML sinks anywhere in client/.
Recap¶
- Mode B is open by default; production needs a
SecurityConfig. - S0
authenticaterejects unauthorized connections before mounting a session. - S1
allowed_originsenables CORS and locks the WS origin. - S2
max_connections/max_message_bytes/max_connections_per_minute/max_events_per_minutebound load (partial). Turnedconcurrent_dispatchon?max_events_per_minutestops being optional. - S3
verify_jwt/jwt_authenticatorauthenticate with a signed JWT. - S6
security_headers/hsts/content_security_policyharden responses; the client is XSS-safe by construction. - Deploy (S5), scale (S4) and server observability (S8) remain on the roadmap — Track S.
API reference
Every SecurityConfig field: tempestweb.server.