Security & RBAC
Authentication
FraiseQL validates authentication tokens automatically — set the appropriate environment variables and tokens are verified before every request reaches your schema.
Strategies
Section titled “Strategies”| Strategy | Best For | Config Key |
|---|---|---|
| JWT | Web and mobile apps | type = "jwt" |
| API Key | Service-to-service | type = "api_key" |
| OAuth 2.0 | Third-party login | type = "oauth2" |
| SAML | Enterprise SSO | type = "saml" |
JWT Authentication
Section titled “JWT Authentication”Configuration
Section titled “Configuration”JWT authentication is configured via environment variables:
# RequiredJWT_SECRET=your-256-bit-secret-key # signing secret (HS256) or public key path (RS256)
# OptionalJWT_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.
Issuing Tokens
Section titled “Issuing Tokens”Your login mutation issues tokens. FraiseQL validates them on subsequent requests.
import fraiseqlfrom fraiseql.scalars import Emailimport jwtimport osfrom datetime import datetime, timedelta
@fraiseql.inputclass LoginInput: email: Email password: str
@fraiseql.typeclass AuthPayload: access_token: str expires_in: int token_type: str
@fraiseql.mutationdef login(info, input: LoginInput) -> AuthPayload: """Authenticate and issue a JWT.""" # fn_login validates credentials and returns user_id + scopes # (implemented as a PostgreSQL function) passThe PostgreSQL function fn_login handles credential validation and returns a user record. Your Python mutation calls it and issues the token:
import jwt, osfrom 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", )import { fraiseqlInput, fraiseqlType, fraiseqlMutation } from 'fraiseql';import { Email } from 'fraiseql/scalars';import jwt from 'jsonwebtoken';
@fraiseqlInput()class LoginInput { email: Email; password: string;}
@fraiseqlType()class AuthPayload { accessToken: string; expiresIn: number; tokenType: string;}
@fraiseqlMutation()function login(input: LoginInput): AuthPayload { // fn_login validates credentials via PostgreSQL function}
function createToken(userId: string, scopes: string[]): string { return jwt.sign( { sub: userId, scopes, iss: 'your-app', aud: 'your-api' }, process.env.JWT_SECRET!, { expiresIn: '1h' }, );}Accessing the Current User
Section titled “Accessing the Current User”Once a request is authenticated, FraiseQL makes the verified claims available in middleware:
import fraiseqlfrom fraiseql.scalars import ID
@fraiseql.middlewaredef 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.authenticateddef my_posts(limit: int = 20) -> list["Post"]: """Get the current user's posts.""" passimport { fraiseqlMiddleware, fraiseqlQuery, authenticated } from 'fraiseql';
fraiseqlMiddleware((request, next) => { if (request.auth) { request.context.currentUserId = request.auth.claims.sub; request.context.userScopes = request.auth.claims.scopes ?? []; } return next(request);});
@fraiseqlQuery({ sqlSource: 'v_user', rowFilter: 'id = {currentUserId}' })@authenticatedfunction me(): User | null {}Protecting Queries and Mutations
Section titled “Protecting Queries and Mutations”from fraiseql.auth import authenticated, requires_scopeimport fraiseql
@fraiseql.query(sql_source="v_post")@authenticateddef 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.""" passimport { authenticated, requiresScope, fraiseqlMutation, fraiseqlQuery } from 'fraiseql';
@fraiseqlQuery({ sqlSource: 'v_post' })@authenticatedfunction posts(limit = 20): Post[] {}
@fraiseqlMutation()@authenticated@requiresScope('write:posts')function createPost(input: CreatePostInput): Post {}Token Refresh
Section titled “Token Refresh”@fraiseql.inputclass RefreshInput: refresh_token: str
@fraiseql.mutationdef 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 passToken Revocation
Section titled “Token Revocation”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 = truebackend = "redis" # or "postgres"require_jti = true # reject JWTs without a jti claimfail_open = false # if store unreachable, deny (not allow) the requestTwo endpoints are available once enabled:
# Revoke own token (self-logout)POST /auth/revokeAuthorization: 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-allBody: { "sub": "<user UUID>" }→ 200 { "revoked_count": 3 }Revoked JTIs are stored until the token’s exp expires — no manual cleanup needed.
API Key Authentication
Section titled “API Key Authentication”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 = trueheader = "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" | sha256sumscopes = ["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.
Configuration
Section titled “Configuration”Protecting Endpoints
Section titled “Protecting Endpoints”from fraiseql.auth import api_key_required, requires_scopeimport fraiseql
@fraiseql.query(sql_source="v_report")@api_key_requireddef 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.""" passIssuing API Keys
Section titled “Issuing API Keys”import fraiseqlimport secrets
@fraiseql.mutation@fraiseql.authenticateddef 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 passOAuth 2.0
Section titled “OAuth 2.0”Configuration
Section titled “Configuration”OAuth provider credentials are configured via environment variables:
# GoogleGOOGLE_CLIENT_ID=your-client-idGOOGLE_CLIENT_SECRET=your-client-secret
# GitHubGITHUB_CLIENT_ID=your-client-idGITHUB_CLIENT_SECRET=your-client-secretOAuth Callback Mutation
Section titled “OAuth Callback Mutation”import fraiseql
@fraiseql.inputclass OAuthCallbackInput: code: str provider: str # "google", "github", etc.
@fraiseql.mutationdef 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. """ passFraiseQL handles the OAuth token exchange. Your PostgreSQL function fn_upsert_oauth_user receives the normalized user info and returns a user record.
SAML (Enterprise SSO)
Section titled “SAML (Enterprise SSO)”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 → FraiseQLConfigure [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 Configuration
Section titled “Auth0 Configuration”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 audienceAUTH0_CLIENT_ID=your-client-idAUTH0_DOMAIN=your-tenant.auth0.comOIDC_CLIENT_SECRET=your-client-secretIf 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.
Security Best Practices
Section titled “Security Best Practices”- Use HTTPS everywhere — Never transmit tokens over unencrypted connections
- Store secrets in environment variables — Never commit
JWT_SECRETto version control - Use a strong secret — At least 32 random bytes:
openssl rand -base64 32 - Set short expiration — Access tokens: 1 hour. Refresh tokens: 7-30 days
- Restrict CORS — Set
[server.cors] originsinfraiseql.tomlto your frontend domain only - Use
HttpOnlycookies for web apps — FraiseQL sets the cookie as__Host-access_token(the__Host-prefix mandatesSecure,Path=/, and noDomainattribute, blocking subdomain override attacks) - Rate-limit login — Protect against brute force via
[security.rate_limiting]infraiseql.toml - Rotate secrets regularly — Use RS256 with key rotation for zero-downtime rotation
Next Steps
Section titled “Next Steps”Multi-Tenancy
OAuth Providers
Troubleshooting
Auth Starter