Server-Side Injection
Server-Side Injection — inject= full reference
FraiseQL’s security model has four layers: JWT verification by the engine, policy-driven authorization, field-level access control, and server-side injection for row filtering. Each layer is independent and can be used without the others.
FraiseQL verifies JWTs automatically on every request. Configuration is done via environment variables, not TOML — there is no [auth] section in fraiseql.toml.
| Environment variable | Description |
|---|---|
JWT_SECRET | Signing secret (HS256) or public key path (RS256) |
JWT_ALGORITHM | HS256, RS256, ES256 — default HS256 |
JWT_ISSUER | Expected iss claim (optional) |
JWT_AUDIENCE | Expected aud claim (optional) |
{ "sub": "user-123", "email": "user@example.com", "roles": ["user", "admin"], "scope": "read:User write:Post", "iat": 1704067200, "exp": 1704070800}curl -X POST http://localhost:8080/graphql \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \ -H "Content-Type: application/json" \ -d '{"query": "{ me { email } }"}'Use Annotated to require scopes for individual fields:
from typing import Annotatedimport fraiseql
@fraiseql.typeclass User: id: ID name: str email: str
# Protected field — requires scope salary: Annotated[Decimal, fraiseql.field(requires_scope="hr:read_salary")]
# Multiple required scopes (all must be present) ssn: Annotated[str, fraiseql.field(requires_scope="hr:read_pii admin:view")]When a client requests a protected field without the required scope, FraiseQL returns null for that field and includes an error entry:
{ "data": { "user": { "name": "John", "email": "john@example.com", "salary": null } }, "errors": [{ "message": "Forbidden: missing scope hr:read_salary", "path": ["user", "salary"] }]}@fraiseql.query( sql_source="v_user", requires_role="admin")def all_users() -> list[User]: """Only admins can list all users.""" pass
@fraiseql.mutation( sql_source="fn_delete_user", requires_scope="admin:delete_user")def delete_user(id: ID) -> bool: """Requires admin:delete_user scope.""" passFor query-scoped and row-level authorization that doesn’t fit field annotations, FraiseQL supports TOML-configured policies using the Elo expression language.
[security]default_policy = "authenticated" # or "public"[[security.rules]]name = "owner_only"rule = "user.id == object.owner_id"description = "User can only access their own data"cacheable = truecache_ttl_seconds = 300[[security.policies]]name = "admin_only"type = "rbac" # rbac | abac | custom | hybridroles = ["admin"]strategy = "any" # any | all | exactlycache_ttl_seconds = 600[[security.field_auth]]type_name = "User"field_name = "email"policy = "admin_only"Use inject= on @fraiseql.query or @fraiseql.mutation to inject JWT claims directly into the SQL call, without a middleware layer:
@fraiseql.query( sql_source="v_post", inject={"author_id": "jwt:sub"})def my_posts() -> list[Post]: """Returns only posts owned by the current user.""" passFraiseQL passes author_id as a parameter to the view. The view can use it in a WHERE clause:
CREATE VIEW v_post ASSELECT jsonb_build_object( 'id', id, 'title', title, 'body', body)FROM tb_postWHERE fk_user = (current_setting('fraiseql.author_id'))::bigint;inject= only supports jwt:<claim> values. Compile-time validation rejects invalid formats.
See Server-Side Injection for the full reference.
The same inject= pattern applies for tenant isolation:
@fraiseql.query( sql_source="v_order", inject={"org_id": "jwt:org_id"})def orders() -> list[Order]: passSee Multi-Tenancy for the full guide including PostgreSQL RLS patterns.
By default, FraiseQL returns raw error messages. Enable sanitization in production to prevent SQL errors and stack traces from reaching clients.
[security.error_sanitization]enabled = true # disabled by defaulthide_implementation_details = true # default: true when enabledsanitize_database_errors = true # default: true when enabledcustom_error_message = "An internal error occurred"ValidationError, Forbidden, and NotFound codes are never sanitized — clients need these to act on errors. Only InternalServerError and DatabaseError codes are replaced with the custom_error_message.
All errors include an extensions object clients can switch on:
{ "errors": [{ "message": "...", "extensions": { "code": "VALIDATION_ERROR", "statusCode": 400 } }]}extensions.code | Sanitizable | When returned | Client action |
|---|---|---|---|
VALIDATION_ERROR | No | Invalid query input | Fix and retry |
NOT_FOUND | No | Resource does not exist | Handle gracefully |
FORBIDDEN | No | Missing scope or role | Show auth error |
UNAUTHENTICATED | No | Missing or invalid JWT | Redirect to login |
RATE_LIMITED | No | Token bucket exhausted | Retry after Retry-After header |
DATABASE_ERROR | Yes | DB constraint or connection failure | Show generic error |
INTERNAL_SERVER_ERROR | Yes | Unclassified server fault | Show generic error |
TIMEOUT | Yes | Request exceeded timeout | Retry or reduce query scope |
[security.rate_limiting]enabled = truerequests_per_second = 100burst_size = 200FraiseQL applies a global token bucket to all requests and per-endpoint limits to auth routes (/auth/start, /auth/callback, /auth/refresh, /auth/logout). The current implementation is in-memory per instance.
See Rate Limiting for the full configuration reference.
For service-to-service authentication where JWT is impractical, FraiseQL supports static API keys stored hashed in the database. Keys are issued once (plaintext returned at creation, never stored) and validated on every request.
[security.api_keys]enabled = trueheader = "X-API-Key" # HTTP header to read the key fromhash_algorithm = "argon2" # "sha256" (fast) or "argon2" (production)storage = "postgres" # "postgres" (production) or "env" (CI only)See Authentication for the database schema and key issuance mutations.
Revoke JWTs before their exp claim expires — for logout, security incidents, or key rotation.
[security.token_revocation]enabled = truebackend = "redis" # "redis" (recommended) or "postgres"require_jti = true # reject JWTs without a jti claimfail_open = false # deny requests if revocation store is unreachableWhen enabled, two endpoints become available:
POST /auth/revoke — revoke the caller’s own token (self-logout)POST /auth/revoke-all — revoke all tokens for a user (requires admin:revoke scope)See Authentication for the full revocation workflow.
Encrypts OAuth state blobs stored between /auth/start and /auth/callback, preventing tampering.
[security.state_encryption]enabled = truealgorithm = "chacha20-poly1305" # or "aes-256-gcm"key_source = "env"key_env = "STATE_ENCRYPTION_KEY"Generate a key:
openssl rand -hex 32Set the result in your environment as STATE_ENCRYPTION_KEY (64-character hex string encoding a 32-byte key).
[security.pkce]enabled = truecode_challenge_method = "S256" # or "plain" (warns at startup in non-dev)state_ttl_secs = 600# redis_url = "${REDIS_URL}" # required for multi-replica deploymentsEnable the audit log via enterprise flags:
[security.enterprise]audit_logging_enabled = trueaudit_log_level = "info" # "debug" | "info" | "warn"Create an audit table in PostgreSQL:
CREATE TABLE ta_audit_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), timestamp TIMESTAMPTZ DEFAULT NOW(), user_id UUID, operation TEXT NOT NULL, query_name TEXT, variables JSONB, ip_address INET, user_agent TEXT, duration_ms INTEGER, success BOOLEAN, error_message TEXT);CORS is configured under [server.cors], not [security.cors]:
[server.cors]origins = ["https://app.example.com", "http://localhost:3000"]credentials = trueFraiseQL works with PostgreSQL RLS policies. Use inject= to pass JWT claims, then reference them in your RLS policy function:
-- Enable RLS on the tableALTER TABLE tb_post ENABLE ROW LEVEL SECURITY;
-- Users can only see their own postsCREATE POLICY post_owner_policy ON tb_post FOR ALL USING (fk_user = current_user_pk());
-- Helper functionCREATE FUNCTION current_user_pk() RETURNS INTEGER AS $$BEGIN RETURN current_setting('app.current_user_pk')::INTEGER;END;$$ LANGUAGE plpgsql;openssl rand -hex 32)requires_scopeinject= or PostgreSQL RLS[security.error_sanitization] enabled in production[server.tls][server.cors] (not wildcard)[security.rate_limiting][security.state_encryption] enabledSTATE_ENCRYPTION_KEY env var set in all environments where enabled = trueenabled = false explicit in CI/test config (not relying on env var absence)[security.pkce] enabled with code_challenge_method = "S256"When a client lacks the required role or scope, FraiseQL returns a structured GraphQL error:
Missing authentication:
{ "errors": [{ "message": "Authentication required", "extensions": { "code": "UNAUTHENTICATED", "statusCode": 401 } }]}Missing role/scope:
{ "errors": [{ "message": "Access denied: role 'viewer' cannot access field 'User.email'", "path": ["user", "email"], "extensions": { "code": "FORBIDDEN", "requiredRole": "admin", "statusCode": 403 } }]}Test field-level authorization:
curl -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $USER_TOKEN" \ -d '{"query": "{ user(id: \"user-123\") { name salary } }"}'Expected: salary returns null with a FORBIDDEN error entry.
Test admin-only query with regular token:
curl -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $USER_TOKEN" \ -d '{"query": "{ allUsers { id name } }"}'Expected: FORBIDDEN error, no data.
Test expired token:
curl -X POST http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $EXPIRED_TOKEN" \ -d '{"query": "{ me { email } }"}'Expected: UNAUTHENTICATED error with "token expired" message.
Test row-level filtering:
# User A's token — should only return User A's postscurl -X POST http://localhost:8080/graphql \ -H "Authorization: Bearer $USER_A_TOKEN" \ -d '{"query": "{ myPosts { title } }"}'Server-Side Injection
Server-Side Injection — inject= full reference
Rate Limiting
Rate Limiting — token bucket configuration
Multi-Tenancy
Multi-Tenancy — tenant isolation patterns