Security
This page documents Quater's security boundaries, defaults, and the places where your application owns the policy.
Prerequisites
Read Quickstart, Actions and CLI, and MCP if your app exposes tools or actions.
Security Model
Quater checks unsafe request properties before user code runs. Auth then runs once for the request's surface; authorization stays in your handler or service code.
Quater does not ship a user system. Your auth hooks decide who the caller is and what scopes they have.
Auth
An authenticator receives the Request and returns AuthContext or None:
from quater import AuthConfig, AuthContext, Quater, Request
async def authenticate(ctx: Request) -> AuthContext | None:
if ctx.headers.get("authorization") != "Bearer demo-token":
return None
return AuthContext(subject="user_123", metadata={"scope": "orders:read"})
app = Quater(auth=[AuthConfig(authenticate, surfaces=["api"])])
@app.get("/me")
async def me(request: Request) -> dict[str, str]:
assert request.auth is not None
return {"subject": request.auth.subject}Missing or rejected auth returns:
401 Unauthorized
UnauthorizedAuth is surface-wide when configured. If a surface has an AuthConfig, every route exposed on that surface is protected unless you mark it public (public=True for every exposed surface, or public=["mcp", ...] for named ones). If a surface has no AuthConfig, that surface is public.
A route is only as safe as its weakest surface
A route exposed on several surfaces is gated by each configured surface's AuthConfig independently. Keep every exposed surface's authenticator strong, or leave the surface public deliberately — public agent surfaces are logged at startup with route names.
Reject before you open the pool
Check the cheap thing first. An authenticator that reads only the Request header rejects a no-token request before any database session opens, so a bad-token flood can't drain the connection pool. When auth needs the database, call await request.resolve(session_resource) only after the token/header shape has passed cheap validation. Handlers can inject that same resource through Annotated[T, session_resource]. Authenticator resource parameters are rejected at startup because they would open resources too early.
Surface Auth
The mcp AuthConfig protects:
POST /mcpGET /mcp/docsinitializetools/listtools/call
If no AuthConfig covers "mcp", those MCP endpoints and methods are public instead. tools/list will disclose every exposed tool=True route, and tools/call can run them.
The cli AuthConfig protects:
- local action discovery
- local action execution
GET /.well-known/quater-actions.jsonPOST /__quater__/actions/call
Route-level auth= no longer exists. Put identity checks in the surface AuthConfig; put roles, ownership, and other authorization checks in the handler or service where the domain data is available.
Approval
Approval is not auth. Auth identifies the caller. Approval confirms one sensitive operation should run for that caller and that exact argument set.
from quater import ApprovalRequest
async def approve_action(ctx: ApprovalRequest) -> bool:
return ctx.auth is not None and ctx.token == "approve-ord_1001"The approval request includes:
- action name
- SHA-256 argument hash
- submitted approval token
- authenticated subject when available
- request context
Quater computes the hash after binding and validation, from the action name and canonical JSON for the arguments the handler will receive. Defaults are included and object key ordering does not change it.
Hosts And Proxies
Use allowed_hosts for deployed apps:
app = Quater(allowed_hosts=["api.example.com"])In strict mode, empty allowed_hosts accepts local development hosts only:
localhost127.0.0.1::1testserver
app.validate_production() rejects missing allowed_hosts and rejects allowed_hosts=["*"].
Use trusted_proxies only for proxy IPs or CIDR ranges you control:
app = Quater(
allowed_hosts=["api.example.com"],
trusted_proxies=["10.0.0.0/8"],
)Quater ignores forwarded host and scheme headers unless the immediate client IP matches trusted_proxies.
Body Limits
max_body_size defaults to 2mb and applies before JSON parsing or form parsing:
app = Quater(
max_body_size="2mb",
max_form_parts=1000,
max_form_field_size="1mb",
max_file_size="2mb",
)If Content-Length exceeds the limit, Quater rejects the request before reading the stream. If no content length exists, adapters enforce the limit while reading the body.
Form and file limits apply after the request body fits inside max_body_size. Set them through constructor options or environment variables such as QUATER_MAX_FILE_SIZE=8mb.
Expected error:
413 Payload Too Large
Payload Too LargeResponse Headers
Strict mode adds baseline headers to normal responses and framework errors:
X-Content-Type-Options: nosniffReferrer-Policy: same-originX-Frame-Options: DENYStrict-Transport-Securityon HTTPS requestsContent-Security-Policywhen configured
Use security="off" only in controlled local or embedded environments.
CORS And MCP Origins
CORS controls browser read access. It does not authenticate users.
from quater import CORSConfig, Quater
app = Quater(
cors=CORSConfig(
allowed_origins=("https://app.example.com",),
allowed_headers=("authorization", "content-type"),
allow_credentials=True,
)
)If allowed_headers is empty, Quater reflects sanitized browser-requested headers. If you configure headers, Quater only advertises those headers. Quater advertises common API methods by default. If you register an extension method with route("PROPFIND", ...) and browser clients call it cross-origin, include that method in allowed_methods.
MCP origin validation uses mcp_allowed_origins first:
app = Quater(
mcp_allowed_origins=["https://client.example"],
auth=[AuthConfig(authenticate, surfaces=["mcp"])],
)If that option is empty and CORS has exact origins, Quater reuses those exact origins. A CORS wildcard does not allow browser-based MCP calls.
Signed Cookies
SignedCookieSigner signs small cookie values with HMAC. It does not encrypt them.
from quater import SignedCookieSigner
signer = SignedCookieSigner("new-secret", fallback_secrets=["old-secret"])
cookie_value = signer.sign("user_123")
subject = signer.verify(cookie_value)Use fallback secrets during rotation. Verification uses constant-time signature comparison.
Request IDs And Access Logs
Quater validates x-request-id before echoing it. Unsafe values are replaced with generated ids before the handler or access logger sees them.
access_logger receives structured metadata after the response is created. It does not receive request bodies, headers, or query strings.
from quater import AccessLogEvent, Quater
async def log_access(event: AccessLogEvent) -> None:
print(event.to_dict())
app = Quater(access_logger=log_access)If the access logger raises, Quater suppresses the exception so logging cannot break the response path. If you need fail-closed audit behavior, use mcp_audit for MCP tool calls.
What Can Go Wrong
Invalid Host header : Configure allowed_hosts or fix the incoming Host header.
Invalid Content-Length header : Send a valid non-negative integer content length.
CORS wildcard origins cannot be used with credentials : Use explicit origins when allow_credentials=True.
Invalid MCP Origin : Add the client origin to mcp_allowed_origins.
Production safety check failed: : Fix each listed item before production startup.
Route path '/mcp' is reserved by Quater. Choose a different application route. : Do not register application routes under Quater protocol paths.
Also See
- Deployment: production checks and server setup.
- MCP: MCP auth and origin validation.
- Actions and CLI: remote action protocol security.
- Reference: Auth: exact auth and approval types.