Skip to content

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.


Authentication flow: client request through JWT verification, scope checks, to database query Authentication flow: client request through JWT verification, scope checks, to database query
schema.compiled.json carries auth metadata from compile time into the Rust runtime.

Your Python schema declares what requires authentication. The compiled schema carries that metadata into the Rust runtime, which enforces it on every request.


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):

Terminal window
curl https://api.example.com/rest/v1/posts \
-H "Authorization: Bearer $TOKEN"

gRPC — Authorization metadata:

Terminal window
grpcurl -plaintext \
-H "authorization: Bearer $TOKEN" \
localhost:8080 BlogService/ListPosts

API key (REST and GraphQL):

Terminal window
curl https://api.example.com/rest/posts \
-H "X-API-Key: $API_KEY"

OIDC/JWT configuration is supplied via environment variables. FraiseQL does not have an [auth] TOML section — all provider credentials and endpoints are runtime configuration:

Terminal window
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.


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.

schema.py
from typing import Annotated
import fraiseql
from fraiseql.scalars import ID
@fraiseql.type
class 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")]
ValueBehaviour
"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.

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 Annotated
import fraiseql
@fraiseql.type
class 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.


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.

schema.py
import fraiseql
from fraiseql.scalars import ID
@fraiseql.type
class 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."""
pass

The 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:

db/schema/02_read/v_post.sql
CREATE VIEW v_post AS
SELECT
p.id,
jsonb_build_object(
'id', p.id::text,
'identifier', p.identifier,
'title', p.title,
'content', p.content,
'author_id', u.id::text
) AS data
FROM tb_post p
JOIN tb_user u ON u.pk_user = p.fk_user
WHERE u.id = $author_id; -- injected from JWT sub claim

To require a role for an entire type (all fields), use requires_role= on @fraiseql.type:

schema.py
import fraiseql
from fraiseql.scalars import ID
@fraiseql.type(requires_role="admin")
class AuditLog:
id: ID
user_id: str
action: str
occurred_at: str

Any query returning AuditLog will be rejected unless the JWT contains the required role. This is validated by the Rust runtime before PostgreSQL is queried.


Security behaviour is configured in fraiseql.toml under [security.*]. These settings are baked into the compiled schema and enforced by the Rust runtime.

fraiseql.toml
[security.enterprise]
enabled = true
log_level = "info" # "debug" | "info" | "warn"
include_sensitive_data = false # Never true in production
async_logging = true
buffer_size = 1000
flush_interval_secs = 5

Prevents internal details from leaking to clients:

fraiseql.toml
[security.error_sanitization]
enabled = true
generic_messages = true # Always true in production
internal_logging = true
leak_sensitive_details = false # Never true in production
user_facing_format = "generic" # "generic" | "simple" | "detailed"

Protects authentication endpoints from brute-force attacks:

fraiseql.toml
[security.rate_limiting]
enabled = true
# Per-IP limits on public auth endpoints
auth_start_max_requests = 100
auth_start_window_secs = 60
auth_callback_max_requests = 50
auth_callback_window_secs = 60
# Per-user limits on authenticated endpoints
auth_refresh_max_requests = 10
auth_refresh_window_secs = 60
auth_logout_max_requests = 20
auth_logout_window_secs = 60
# Failed login lockout
failed_login_max_requests = 5
failed_login_window_secs = 3600 # 1 hour lockout
fraiseql.toml
[security.state_encryption]
enabled = true
algorithm = "chacha20-poly1305"

The encryption key is never stored in fraiseql.toml. Provide it at runtime:

Terminal window
export STATE_ENCRYPTION_KEY=$(openssl rand -base64 32)

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 → FraiseQL

Point 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
Terminal window
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.


fraiseql.toml (development)
[security.enterprise]
log_level = "debug"
include_sensitive_data = true
async_logging = false
[security.error_sanitization]
user_facing_format = "detailed"
[security.rate_limiting]
auth_start_max_requests = 10000
failed_login_max_requests = 10000
fraiseql.toml (production)
[security.enterprise]
log_level = "info"
include_sensitive_data = false
[security.error_sanitization]
generic_messages = true
leak_sensitive_details = false
[security.rate_limiting]
auth_start_max_requests = 100
failed_login_max_requests = 5

  1. Never store secrets in fraiseql.toml — Use environment variables for OIDC_CLIENT_SECRET, STATE_ENCRYPTION_KEY, and any other secrets.
  2. Use HTTPS everywhere — Never transmit tokens over unencrypted connections.
  3. Use on_deny="mask" carefully — Masking is appropriate for optional enrichment fields. For sensitive data, prefer "reject" (the default).
  4. Derive identity from JWT claims, never from client input — Use inject={"user_id": "jwt:sub"} rather than accepting a user_id GraphQL argument.
  5. Enable rate limiting in production — Set failed_login_max_requests = 5 to limit brute-force attempts.
  6. Rotate OIDC keys via the provider — FraiseQL fetches the JWKS endpoint automatically; key rotation requires no redeploy.

Rate Limiting

Rate Limiting — Token bucket configuration and failed login protection