Security
Pyxle includes CSRF protection, CORS support, and HEAD element sanitisation out of the box.
CSRF protection
CSRF (Cross-Site Request Forgery) protection is enabled by default using a double-submit cookie pattern.
How it works
- Pyxle sets a CSRF cookie on every response. The default name is namespaced by the app's bind port —
pyxle-csrf-8000for an app serving on port 8000 - State-changing requests (
POST,PUT,PATCH,DELETE) must echo the cookie value back, either in thex-csrf-tokenheader or — for form-encoded submissions, such as a<Form>post before hydration — in a hidden_csrf_tokenform field - The framework validates that the submitted token matches the cookie using constant-time comparison
- If they do not match, a
403 Forbiddenresponse is returned
The <Form> component and useAction hook handle this automatically.
Why the cookie name carries the port
Browsers scope cookies to a host, not a host+port. With a fixed cookie name, two Pyxle apps on the same host — say two dev servers on 127.0.0.1:8000 and 127.0.0.1:8001 — would keep overwriting each other's token, and every action in the other app would fail with 403 CSRF token mismatch. Namespacing the default name with the bind port (read from the ASGI connection scope) gives each app its own cookie. The bind port is stable per app in development and behind a production reverse proxy, where the public port (443) differs from the bind port but never varies per request.
Set csrf.cookieName to pin an explicit name instead — do this if you serve the same app from multiple ports and want one shared token, or if your production setup pre-renders pages with pyxle build and then serves them on a different port than pyxle.config.json declares (the pre-baked pages embed the cookie name). The same applies if you run the ASGI app over a unix socket (e.g. raw uvicorn --uds): with no bind port to namespace by, pin cookieName explicitly so the middleware and rendered pages always agree.
Upgrading from an older Pyxle: a leftover pyxle-csrf cookie is simply ignored — the token re-issues under the namespaced name on the next page load. No action needed.
Form bodies and uploads
Pyxle never buffers an unbounded request body to find _csrf_token:
application/x-www-form-urlencodedbodies are buffered up to 1 MiB; a larger body without the header token is rejected with413.multipart/form-databodies are stream-parsed only until the_csrf_tokenfield is found — file parts after the token are passed through to your handler untouched, never buffered by the CSRF layer.<Form>renders the hidden token field first, so an upload of any size works out of the box. The scan is capped at 1 MiB of pre-token data; if the field hasn't appeared by then (or isn't present at all), the request is rejected with403and a message telling you to send the token in thex-csrf-tokenheader or move_csrf_tokenbefore the file fields.
Configuration
{
"csrf": {
"enabled": true,
"cookieName": "my-app-csrf",
"headerName": "x-csrf-token",
"cookieSecure": false,
"cookieSameSite": "lax",
"exemptPaths": ["/api/webhooks"]
}
}| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Enable/disable CSRF |
cookieName |
string |
auto: "pyxle-csrf-<port>" |
Cookie name; unset → namespaced by the bind port (see above) |
headerName |
string |
"x-csrf-token" |
Header name |
cookieSecure |
boolean |
false |
Set Secure flag (enable in production with HTTPS) |
cookieSameSite |
string |
"lax" |
SameSite attribute ("strict", "lax", or "none") |
exemptPaths |
string[] |
[] |
Paths exempt from CSRF checks; an entry matches its exact path and anything beneath it at a / boundary (/api/webhooks does not exempt /api/webhooks-admin) — see matching details |
Disabling CSRF
{
"csrf": false
}Or with the object form:
{
"csrf": {
"enabled": false
}
}Token validation for custom fetch calls
If you make POST requests outside of <Form> or useAction, read the CSRF token from the cookie and include it as a header. Don't hardcode the cookie name — the default is port-namespaced, so resolve it from the window.__PYXLE_CSRF_COOKIE__ global Pyxle injects into every page:
function getCsrfToken() {
const cookieName = window.__PYXLE_CSRF_COOKIE__ ?? 'pyxle-csrf';
for (const part of document.cookie.split(';')) {
const eq = part.indexOf('=');
if (eq !== -1 && part.slice(0, eq).trim() === cookieName) {
return decodeURIComponent(part.slice(eq + 1));
}
}
return '';
}
await fetch('/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': getCsrfToken(),
},
body: JSON.stringify({ key: 'value' }),
});A customised csrf.headerName is exposed the same way, as window.__PYXLE_CSRF_HEADER__ (falling back to x-csrf-token). The globals always carry the effective names — including the auto port-namespaced cookie name, which client code cannot derive itself behind a reverse proxy.
Signed cookies and tokens
sign_cookie and verify_cookie (importable from pyxle) attach a tamper-proof signature to a string and verify it later — the building block for a signed session id, a "remember me" cookie, or a stateless link such as an unsubscribe or password-reset URL. This is signing, not encryption: the value stays readable; a valid signature only proves it was produced by a holder of the secret.
from pyxle import sign_cookie, verify_cookie
token = sign_cookie("user-42") # "user-42.<hmac>" (signs with PYXLE_SECRET_KEY)
verify_cookie(token) # "user-42"
verify_cookie("user-42.deadbeef") # None (bad signature → reject)The signature is a full HMAC-SHA256 digest, compared in constant time. The secret is read from the PYXLE_SECRET_KEY environment variable, or passed explicitly as secret_key=. Signing is meaningless without a secret, so a missing one raises MissingSecretKeyError rather than returning an unprotected value — it fails closed. Import the exception from pyxle.security (it is not re-exported from the top-level pyxle package):
from pyxle.security import MissingSecretKeyErrorUse salt= to namespace signatures by purpose, so a token signed for one flow can't be replayed in another even under the same secret:
reset = sign_cookie(email, salt="password-reset")
verify_cookie(reset, salt="login") # None (wrong salt → reject)verify_cookie returns None on any signature or format failure. Because a validly-signed empty string is itself falsy, test the result with is not None, not truthiness:
value = verify_cookie(token)
if value is not None:
... # trust `value`CORS configuration
Configure Cross-Origin Resource Sharing for API access from other domains:
{
"cors": {
"origins": ["https://app.example.com", "https://admin.example.com"],
"methods": ["GET", "POST", "PUT", "DELETE"],
"headers": ["Authorization", "Content-Type"],
"credentials": true,
"maxAge": 600
}
}| Option | Type | Default | Description |
|---|---|---|---|
origins |
string[] |
[] |
Allowed origins (CORS is disabled if empty) |
methods |
string[] |
["GET","POST","PUT","PATCH","DELETE","OPTIONS"] |
Allowed methods |
headers |
string[] |
[] |
Allowed request headers |
credentials |
boolean |
false |
Allow credentials (cookies, auth headers) |
maxAge |
integer |
600 |
Preflight cache duration in seconds |
CORS is disabled by default (no origins configured). It is only active when you explicitly list allowed origins.
Head element sanitisation
Dynamic head content (whether from a <Head> JSX block or the lower-level HEAD variable) is automatically sanitised to prevent XSS:
- Tag allowlist: only
<title>,<meta>,<link>,<script>, and<style>may appear; any other tag (<base>,<iframe>,<object>, …) is dropped - Attribute escaping: elements are parsed and rebuilt with every attribute value HTML-escaped, so a quote in dynamic data cannot break out of its attribute and inject markup
- Title text escaping:
<and>inside<title>elements are escaped to<and>, and markup injected after a closing</title>is discarded - Event handler stripping:
onclick,onerror,onload, and allon*attributes are removed - Dangerous URL neutralisation:
javascript:,vbscript:, anddata:URLs inhref,src, andactionattributes are emptied - Meta refresh rejected:
<meta http-equiv="refresh">is dropped as an open-redirect vector
The text content of inline <script>/<style> head elements is trusted author code and is not sanitised. Never interpolate user-supplied data into inline script or style content.
This sanitisation is applied to all head elements from every source — layout <Head> blocks, page <Head> blocks, and the legacy HEAD Python variable.
Best practice
React already escapes text content by default when you interpolate into JSX, so the <Head> component inherits XSS protection automatically:
import { Head } from 'pyxle/client';
export default function Page({ data }) {
return (
<>
<Head>
<title>{data.userSubmittedTitle}</title>
</Head>
{/* ... */}
</>
);
}React escapes data.userSubmittedTitle in the text node, and Pyxle's head sanitiser provides a second layer of defense. You don't need html.escape() for JSX-driven head content.
For the lower-level HEAD Python variable with user-supplied data, escape explicitly:
from html import escape
def HEAD(data):
return f'<title>{escape(data["title"])}</title>'Security headers
Pyxle automatically sets a baseline of security headers on every response, in both dev and production — you don't need to add these at your proxy:
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-originX-Frame-Options: SAMEORIGIN allows your own pages to frame each other (e.g. a preview iframe) while blocking third-party framing. If you need stricter framing, set DENY at your reverse proxy — a proxy header overrides the app's.
Two headers are not set for you, because a safe value depends on your app:
Content-Security-Policy— a good policy is app-specific (which script/style/img/connect sources you allow), and a wrong one silently breaks your pages. Add it at your reverse proxy once you've settled your sources. A strict starting point isdefault-src 'self'.Strict-Transport-Security(HSTS) — belongs at the TLS-terminating layer (your proxy or CDN), since it only makes sense once you're serving HTTPS. Example:Strict-Transport-Security: max-age=31536000; includeSubDomains.
Example Nginx addition for the two headers Pyxle leaves to you:
add_header Content-Security-Policy "default-src 'self'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;Rate limiting
Pyxle ships a built-in token-bucket rate limiter, off by default. Enable it from pyxle.config.json to cap how fast a single client can hit the app and blunt brute-force and scraping abuse:
{
"rateLimit": {
"requests": 100,
"window": 60,
"exemptPaths": ["/health"]
}
}A client may burst up to requests and then sustains requests / window per second; over-limit requests get 429 Too Many Requests with a Retry-After header. The store is in-memory and per-process, so for a single shared limit across --workers or hosts, rate-limit at your reverse proxy. Full details: middleware guide and configuration reference.
Environment variable safety
- Variables without the
PYXLE_PUBLIC_prefix are server-only and never appear in client bundles - Loader and action return values are serialised to JSON and sent to the client -- never include secrets in return values
- Use
.env.localfor secrets and add it to.gitignore
Reporting a vulnerability
Found a security issue in Pyxle itself? Please report it privately — don't open a public issue. Use a GitHub private advisory or email [email protected]. You'll get an acknowledgement within 72 hours. See SECURITY.md for the full policy and scope.
Next steps
- Deploy to production: Deployment
- Full config reference: Configuration