Security & RBAC
Authentication
FraiseQL validates authentication tokens automatically in the Rust runtime — configure the appropriate settings and tokens are verified before every request reaches your schema. The Python SDK is compile-time only: it declares what requires authentication, and the Rust runtime enforces it.
How Authentication Works
Section titled “How Authentication Works”Your Python schema declares what requires authentication. The compiled schema carries that metadata into the Rust runtime, which enforces it on every request.
Authentication Across Transports
Section titled “Authentication Across Transports”Authentication applies identically across all transports. The same JWT or API key that works for GraphQL works for REST and gRPC.
REST — Bearer token in Authorization header (the default path is /rest/v1; configure with [rest] path):
curl https://api.example.com/rest/v1/posts \ -H "Authorization: Bearer $TOKEN"gRPC — Authorization metadata:
grpcurl -plaintext \ -H "authorization: Bearer $TOKEN" \ localhost:8080 BlogService/ListPostsAPI key (REST and GraphQL):
curl https://api.example.com/rest/posts \ -H "X-API-Key: $API_KEY"OIDC / JWT Configuration
Section titled “OIDC / JWT Configuration”OIDC/JWT configuration is supplied via environment variables. FraiseQL does not have an [auth] TOML section — all provider credentials and endpoints are runtime configuration:
export OIDC_ISSUER_URL="https://your-idp.example.com/"export OIDC_CLIENT_ID="your-client-id"export OIDC_CLIENT_SECRET="your-client-secret"export OIDC_REDIRECT_URI="https://your-app.example.com/auth/callback"FraiseQL fetches the provider’s JWKS from ${OIDC_ISSUER_URL}/.well-known/openid-configuration and validates every incoming JWT against it. No middleware code required.
Field-Level Access Control
Section titled “Field-Level Access Control”FraiseQL implements scope-based access control at the field level. Use fraiseql.field(requires_scope=...) inside a @fraiseql.type to declare which JWT scope a client must hold to read a field.
from typing import Annotatedimport fraiseqlfrom fraiseql.scalars import ID
@fraiseql.typeclass User: id: ID name: str # Any authenticated user can read email email: str # Requires the users:read scope phone: Annotated[str | None, fraiseql.field(requires_scope="users:read")] # Requires admin:read — returns null for unauthorized users instead of rejecting admin_notes: Annotated[str | None, fraiseql.field( requires_scope="admin:read", on_deny="mask", )] # Requires hr:view_pii — rejects the entire query if scope is missing salary: Annotated[int | None, fraiseql.field(requires_scope="hr:view_pii")]on_deny Policies
Section titled “on_deny Policies”| Value | Behaviour |
|---|---|
"reject" (default) | Entire query fails with a FORBIDDEN error if the scope is missing |
"mask" | Query succeeds; the field is returned as null for unauthorized users |
Use "mask" for fields that are optional enrichments (e.g. internal notes). Use "reject" (or omit on_deny) for fields that must never leak.
Using Annotated
Section titled “Using Annotated”The fraiseql.field(...) call returns a FieldConfig object. It must be wrapped with Annotated so that the Python type annotation still names the correct GraphQL type:
from typing import Annotatedimport fraiseql
@fraiseql.typeclass Post: id: fraiseql.ID title: str # Annotated[<GraphQL type>, <field metadata>] internal_notes: Annotated[str | None, fraiseql.field( requires_scope="admin:read", description="Internal editorial notes, never shown publicly.", )]The compiler reads the FieldConfig metadata and bakes the scope requirement into schema.compiled.json. The Rust runtime checks the JWT’s scopes claim against these requirements before returning field values.
Injecting JWT Claims into Queries
Section titled “Injecting JWT Claims into Queries”Use inject= on @fraiseql.query or @fraiseql.mutation to forward a JWT claim directly to a SQL parameter. This is the correct way to scope data by the authenticated user — no Python runtime code involved.
import fraiseqlfrom fraiseql.scalars import ID
@fraiseql.typeclass Post: id: ID title: str content: str
@fraiseql.query( sql_source="v_post", inject={"author_id": "jwt:sub"}, # injects JWT sub claim as $author_id)def my_posts(limit: int = 20) -> list[Post]: """Returns only the posts authored by the authenticated user.""" passThe inject mapping has the form {"sql_param_name": "jwt:<claim_name>"}. The Rust runtime reads the verified JWT claim and passes it to the SQL view as a parameter — the claim value is never supplied by the client. See Server-Side Injection for the full reference, including TOML session variables, HTTP header injection, and automatic timestamps.
Your PostgreSQL view filters by it:
CREATE VIEW v_post ASSELECT p.id, jsonb_build_object( 'id', p.id::text, 'identifier', p.identifier, 'title', p.title, 'content', p.content, 'author_id', u.id::text ) AS dataFROM tb_post pJOIN tb_user u ON u.pk_user = p.fk_userWHERE u.id = $author_id; -- injected from JWT sub claimType-Level Access Control
Section titled “Type-Level Access Control”To require a role for an entire type (all fields), use requires_role= on @fraiseql.type:
import fraiseqlfrom fraiseql.scalars import ID
@fraiseql.type(requires_role="admin")class AuditLog: id: ID user_id: str action: str occurred_at: strAny query returning AuditLog will be rejected unless the JWT contains the required role. This is validated by the Rust runtime before PostgreSQL is queried.
Server-Level Security Configuration
Section titled “Server-Level Security Configuration”Security behaviour is configured in fraiseql.toml under [security.*]. These settings are baked into the compiled schema and enforced by the Rust runtime.
Audit Logging
Section titled “Audit Logging”[security.enterprise]enabled = truelog_level = "info" # "debug" | "info" | "warn"include_sensitive_data = false # Never true in productionasync_logging = truebuffer_size = 1000flush_interval_secs = 5Error Sanitization
Section titled “Error Sanitization”Prevents internal details from leaking to clients:
[security.error_sanitization]enabled = truegeneric_messages = true # Always true in productioninternal_logging = trueleak_sensitive_details = false # Never true in productionuser_facing_format = "generic" # "generic" | "simple" | "detailed"Rate Limiting
Section titled “Rate Limiting”Protects authentication endpoints from brute-force attacks:
[security.rate_limiting]enabled = true
# Per-IP limits on public auth endpointsauth_start_max_requests = 100auth_start_window_secs = 60auth_callback_max_requests = 50auth_callback_window_secs = 60
# Per-user limits on authenticated endpointsauth_refresh_max_requests = 10auth_refresh_window_secs = 60auth_logout_max_requests = 20auth_logout_window_secs = 60
# Failed login lockoutfailed_login_max_requests = 5failed_login_window_secs = 3600 # 1 hour lockoutState Encryption (PKCE)
Section titled “State Encryption (PKCE)”[security.state_encryption]enabled = truealgorithm = "chacha20-poly1305"The encryption key is never stored in fraiseql.toml. Provide it at runtime:
export STATE_ENCRYPTION_KEY=$(openssl rand -base64 32)SAML (Enterprise SSO)
Section titled “SAML (Enterprise SSO)”FraiseQL does not implement a SAML Service Provider natively. The standard pattern is an identity proxy that accepts SAML assertions and issues JWKS-signed JWTs:
Browser → SAML IdP → Identity Proxy (Keycloak / Dex / Auth0 / Okta) → JWT → FraiseQLPoint FraiseQL at the proxy’s OIDC endpoint via environment variables. The proxy handles SAML assertion validation and attribute mapping — no changes to FraiseQL are needed.
Recommended proxies:
- Keycloak — self-hosted, open source, full SAML federation
- Dex — lightweight, Kubernetes-native
- Auth0 — managed, supports SAML federation
- Okta — managed enterprise
Auth0 Example
Section titled “Auth0 Example”export OIDC_ISSUER_URL="https://your-tenant.auth0.com/"export OIDC_CLIENT_ID="your-auth0-client-id"export OIDC_CLIENT_SECRET="your-auth0-client-secret"export OIDC_REDIRECT_URI="https://your-app.example.com/auth/callback"Custom claims added via Auth0 Rules or Actions (e.g. roles, org_id) are available as jwt:<claim_name> in inject= parameters.
Preset Configurations
Section titled “Preset Configurations”Development
Section titled “Development”[security.enterprise]log_level = "debug"include_sensitive_data = trueasync_logging = false
[security.error_sanitization]user_facing_format = "detailed"
[security.rate_limiting]auth_start_max_requests = 10000failed_login_max_requests = 10000Production
Section titled “Production”[security.enterprise]log_level = "info"include_sensitive_data = false
[security.error_sanitization]generic_messages = trueleak_sensitive_details = false
[security.rate_limiting]auth_start_max_requests = 100failed_login_max_requests = 5Security Best Practices
Section titled “Security Best Practices”- Never store secrets in
fraiseql.toml— Use environment variables forOIDC_CLIENT_SECRET,STATE_ENCRYPTION_KEY, and any other secrets. - Use HTTPS everywhere — Never transmit tokens over unencrypted connections.
- Use
on_deny="mask"carefully — Masking is appropriate for optional enrichment fields. For sensitive data, prefer"reject"(the default). - Derive identity from JWT claims, never from client input — Use
inject={"user_id": "jwt:sub"}rather than accepting auser_idGraphQL argument. - Enable rate limiting in production — Set
failed_login_max_requests = 5to limit brute-force attempts. - Rotate OIDC keys via the provider — FraiseQL fetches the JWKS endpoint automatically; key rotation requires no redeploy.
Next Steps
Section titled “Next Steps”Rate Limiting
Rate Limiting — Token bucket configuration and failed login protection
Multi-Tenancy
TOML Config Reference
Troubleshooting
Auth Starter