Skip to content

Authentication

FraiseQL validates authentication tokens automatically — set the appropriate environment variables and tokens are verified before every request reaches your schema.

StrategyBest ForConfig Key
JWTWeb and mobile appstype = "jwt"
API KeyService-to-servicetype = "api_key"
OAuth 2.0Third-party logintype = "oauth2"
SAMLEnterprise SSOtype = "saml"

JWT authentication is configured via environment variables:

Terminal window
# Required
JWT_SECRET=your-256-bit-secret-key # signing secret (HS256) or public key path (RS256)
# Optional
JWT_ALGORITHM=HS256 # HS256 | RS256 | ES256 (default: HS256)
JWT_ISSUER=your-app # expected iss claim (optional)
JWT_AUDIENCE=your-api # expected aud claim (optional)

FraiseQL validates every incoming token against these settings before routing the request to your schema. No middleware code required.

Your login mutation issues tokens. FraiseQL validates them on subsequent requests.

schema.py
import fraiseql
from fraiseql.scalars import Email
import jwt
import os
from datetime import datetime, timedelta
@fraiseql.input
class LoginInput:
email: Email
password: str
@fraiseql.type
class AuthPayload:
access_token: str
expires_in: int
token_type: str
@fraiseql.mutation
def login(info, input: LoginInput) -> AuthPayload:
"""Authenticate and issue a JWT."""
# fn_login validates credentials and returns user_id + scopes
# (implemented as a PostgreSQL function)
pass

The PostgreSQL function fn_login handles credential validation and returns a user record. Your Python mutation calls it and issues the token:

auth.py
import jwt, os
from datetime import datetime, timedelta
def create_token(user_id: str, scopes: list[str]) -> str:
return jwt.encode(
{
"sub": user_id,
"scopes": scopes,
"iss": "your-app",
"aud": "your-api",
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(hours=1),
},
os.environ["JWT_SECRET"],
algorithm="HS256",
)

Once a request is authenticated, FraiseQL makes the verified claims available in middleware:

schema.py
import fraiseql
from fraiseql.scalars import ID
@fraiseql.middleware
def set_user_context(request, next):
"""Make verified JWT claims available to queries and mutations."""
if request.auth:
request.context["current_user_id"] = request.auth.claims.get("sub")
request.context["user_scopes"] = request.auth.claims.get("scopes", [])
return next(request)
# Use request.context in your query's row_filter
@fraiseql.query(
sql_source="v_user",
id_arg="id",
row_filter="id = {current_user_id}"
)
def me() -> "User | None":
"""Get the currently authenticated user."""
pass
@fraiseql.query(sql_source="v_post")
@fraiseql.authenticated
def my_posts(limit: int = 20) -> list["Post"]:
"""Get the current user's posts."""
pass
schema.py
from fraiseql.auth import authenticated, requires_scope
import fraiseql
@fraiseql.query(sql_source="v_post")
@authenticated
def posts(limit: int = 20) -> list["Post"]:
"""Requires valid JWT."""
pass
@fraiseql.mutation
@authenticated
@requires_scope("write:posts")
def create_post(info, input: "CreatePostInput") -> "Post":
"""Requires valid JWT with write:posts scope."""
pass
@fraiseql.mutation
@authenticated
@requires_scope("admin:posts")
def delete_post(info, id: "ID") -> bool:
"""Requires admin:posts scope."""
pass
schema.py
@fraiseql.input
class RefreshInput:
refresh_token: str
@fraiseql.mutation
def refresh_token(info, input: RefreshInput) -> AuthPayload:
"""Exchange a refresh token for a new access token."""
# fn_refresh_token validates the refresh token and returns new claims
pass

Revoke a JWT before its exp claim expires — for logout, key rotation, or security incidents. Configure the revocation store in fraiseql.toml:

[security.token_revocation]
enabled = true
backend = "redis" # or "postgres"
require_jti = true # reject JWTs without a jti claim
fail_open = false # if store unreachable, deny (not allow) the request

Two endpoints are available once enabled:

Terminal window
# Revoke own token (self-logout)
POST /auth/revoke
Authorization: Bearer <token>
Body: { "token": "<JWT>" }
200 { "revoked": true, "expires_at": "..." }
# Revoke all tokens for a user (admin only, requires scope "admin:revoke")
POST /auth/revoke-all
Body: { "sub": "<user UUID>" }
200 { "revoked_count": 3 }

Revoked JTIs are stored until the token’s exp expires — no manual cleanup needed.


API key authentication lets service accounts and CI pipelines authenticate without a JWT. Configure it via [security.api_keys] in fraiseql.toml:

[security.api_keys]
enabled = true
header = "X-API-Key"
hash_algorithm = "sha256" # "sha256" (fast, CI use) or "argon2" (production)
storage = "postgres" # or "env" for static keys (testing/CI only)
# Static keys — testing and CI only. Never in production.
[[security.api_keys.static]]
key_hash = "sha256:abc123..." # echo -n "secret" | sha256sum
scopes = ["read:*"]
name = "ci-readonly"

For production, store hashed keys in PostgreSQL:

CREATE TABLE fraiseql_api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
scopes JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX ON fraiseql_api_keys(key_hash) WHERE revoked_at IS NULL;

Requests authenticate with the key in the X-API-Key header. FraiseQL checks the key before falling through to JWT extraction — a missing key does not cause a 401 by itself.

schema.py
from fraiseql.auth import api_key_required, requires_scope
import fraiseql
@fraiseql.query(sql_source="v_report")
@api_key_required
def reports(limit: int = 100) -> list["Report"]:
"""Requires a valid API key."""
pass
@fraiseql.mutation
@api_key_required
@requires_scope("write:data")
def ingest_data(info, payload: "IngestInput") -> bool:
"""Requires an API key with write:data scope."""
pass
schema.py
import fraiseql
import secrets
@fraiseql.mutation
@fraiseql.authenticated
def create_api_key(info, name: str, scopes: list[str]) -> "ApiKey":
"""Create a new API key for the current user."""
# The key value is returned once and never stored in plaintext
# fn_create_api_key stores a hash and returns the full key for display
pass

OAuth provider credentials are configured via environment variables:

Terminal window
# Google
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
# GitHub
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
schema.py
import fraiseql
@fraiseql.input
class OAuthCallbackInput:
code: str
provider: str # "google", "github", etc.
@fraiseql.mutation
def oauth_callback(info, input: OAuthCallbackInput) -> "AuthPayload":
"""Exchange an OAuth code for a FraiseQL JWT.
FraiseQL exchanges the code for the provider's token,
fetches user info, and calls fn_upsert_oauth_user.
"""
pass

FraiseQL handles the OAuth token exchange. Your PostgreSQL function fn_upsert_oauth_user receives the normalized user info and returns a user record.


FraiseQL does not implement a SAML Service Provider natively. The standard approach for microservices is an identity proxy that accepts SAML assertions from your IdP and issues JWKS-signed JWTs to FraiseQL:

Browser → SAML IdP → Identity Proxy (Keycloak / Dex / Auth0 / Okta) → JWT → FraiseQL

Configure [security.pkce] to point at the proxy’s OIDC endpoint. The proxy handles SAML assertion validation and attribute mapping — no code changes to FraiseQL needed.

Recommended proxies:

  • Keycloak — self-hosted, open source, full SAML federation
  • Dex — lightweight, Kubernetes-native
  • Auth0 — managed, supports SAML federation
  • Okta — managed enterprise

Auth0 is a supported OIDC provider. Configure FraiseQL with your Auth0 tenant:

[security.pkce]
issuer_url = "https://{your-auth0-domain}.auth0.com/"
client_id = "${AUTH0_CLIENT_ID}"
audience = "https://api.yourdomain.com" # your Auth0 API audience
Terminal window
AUTH0_CLIENT_ID=your-client-id
AUTH0_DOMAIN=your-tenant.auth0.com
OIDC_CLIENT_SECRET=your-client-secret

If you use Auth0 Rules or Actions to add custom claims (e.g. roles), they are available in jwt:* inject parameters using the full claim namespace.


  1. Use HTTPS everywhere — Never transmit tokens over unencrypted connections
  2. Store secrets in environment variables — Never commit JWT_SECRET to version control
  3. Use a strong secret — At least 32 random bytes: openssl rand -base64 32
  4. Set short expiration — Access tokens: 1 hour. Refresh tokens: 7-30 days
  5. Restrict CORS — Set [server.cors] origins in fraiseql.toml to your frontend domain only
  6. Use HttpOnly cookies for web apps — FraiseQL sets the cookie as __Host-access_token (the __Host- prefix mandates Secure, Path=/, and no Domain attribute, blocking subdomain override attacks)
  7. Rate-limit login — Protect against brute force via [security.rate_limiting] in fraiseql.toml
  8. Rotate secrets regularly — Use RS256 with key rotation for zero-downtime rotation