Skip to content

Security

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.

All security features — JWT/OIDC authentication, field-level RBAC, server-side injection, error sanitization — apply identically across GraphQL, REST, and gRPC transports. The Executor is transport-agnostic: security is enforced before the transport layer serializes the response. There is no separate security configuration per transport.

FraiseQL verifies JWTs automatically on every request. JWT secret and algorithm settings are configured via environment variables. OIDC provider identity (for browser login flows using PKCE) is configured in the [security.pkce] section of fraiseql.toml — see OIDC client identity below.

Environment variableDescription
JWT_SECRETSigning secret (HS256) or public key path (RS256)
JWT_ALGORITHMHS256, RS256, ES256 — default HS256
JWT_ISSUERExpected iss claim (optional)
JWT_AUDIENCEExpected aud claim (optional)
{
"sub": "user-123",
"email": "user@example.com",
"roles": ["user", "admin"],
"scope": "read:User write:Post",
"iat": 1704067200,
"exp": 1704070800
}
Terminal window
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 Annotated
import fraiseql
@fraiseql.type
class 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."""
pass

For 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]] — Elo expression rules

Section titled “[[security.rules]] — Elo expression rules”
[[security.rules]]
name = "owner_only"
rule = "user.id == object.owner_id"
description = "User can only access their own data"
cacheable = true
cache_ttl_seconds = 300

[[security.policies]] — RBAC/ABAC policies

Section titled “[[security.policies]] — RBAC/ABAC policies”
[[security.policies]]
name = "admin_only"
type = "rbac" # rbac | abac | custom | hybrid
roles = ["admin"]
strategy = "any" # any | all | exactly
cache_ttl_seconds = 600

[[security.field_auth]] — per-field policy binding

Section titled “[[security.field_auth]] — per-field policy binding”
[[security.field_auth]]
type_name = "User"
field_name = "email"
policy = "admin_only"

Roles, permissions, and assignments can be managed at runtime via the built-in REST API:

POST /api/rbac/roles
GET /api/rbac/roles
POST /api/rbac/permissions
GET /api/rbac/permissions
POST /api/rbac/assignments
GET /api/rbac/assignments

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."""
pass

FraiseQL passes author_id as a parameter to the view. The view can use it in a WHERE clause:

CREATE VIEW v_post AS
SELECT
p.id,
jsonb_build_object(
'id', p.id,
'title', p.title,
'body', p.body
) AS data
FROM tb_post p
WHERE p.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]:
pass

See 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 default
hide_implementation_details = true # default: true when enabled
sanitize_database_errors = true # default: true when enabled
custom_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.codeSanitizableWhen returnedClient action
VALIDATION_ERRORNoInvalid query inputFix and retry
NOT_FOUNDNoResource does not existHandle gracefully
FORBIDDENNoMissing scope or roleShow auth error
UNAUTHENTICATEDNoMissing or invalid JWTRedirect to login
RATE_LIMITEDNoToken bucket exhaustedRetry after Retry-After header
DATABASE_ERRORYesDB constraint or connection failureShow generic error
INTERNAL_SERVER_ERRORYesUnclassified server faultShow generic error
TIMEOUTYesRequest exceeded timeoutRetry or reduce query scope

[security.rate_limiting]
enabled = true
requests_per_second = 100
burst_size = 200

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


FraiseQL enforces hard limits on query complexity to prevent abuse:

LimitValueConfigurable
Max query depth10Yes ([validation] max_query_depth)
Max aliases30Yes ([validation] max_aliases)
Max variables per request1,000No (hard constant)

The variables limit counts top-level keys in the variables JSON object. Requests exceeding 1,000 variables are rejected with a VALIDATION_ERROR before execution. This is not configurable — if your use case requires more than 1,000 variables, restructure the query to use fewer parameterized inputs (e.g., pass an array instead of individual variables).


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 = true
header = "X-API-Key" # HTTP header to read the key from
hash_algorithm = "sha256" # "sha256" is the supported value
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 = true
backend = "redis" # "redis" (recommended) or "postgres"
require_jti = true # reject JWTs without a jti claim
fail_open = false # deny requests if revocation store is unreachable

When 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 = true
algorithm = "chacha20-poly1305" # or "aes-256-gcm"
key_source = "env"
key_env = "STATE_ENCRYPTION_KEY"

Generate a key:

Terminal window
openssl rand -hex 32

Set the result in your environment as STATE_ENCRYPTION_KEY (64-character hex string encoding a 32-byte key).

[security.pkce]
enabled = true
code_challenge_method = "S256" # or "plain" (warns at startup in non-dev)
state_ttl_secs = 600
# redis_url = "${REDIS_URL}" # required for multi-replica deployments

To use PKCE browser login flows, configure your OIDC provider client identity in [security.pkce]:

[security.pkce]
issuer_url = "https://accounts.google.com"
client_id = "my-fraiseql-client"
redirect_uri = "https://api.example.com/auth/callback"

The OIDC client secret is never stored in fraiseql.toml. Provide it at runtime via the OIDC_CLIENT_SECRET environment variable (or whichever name your identity provider uses).


Enable the audit log via enterprise flags:

[security.enterprise]
audit_logging_enabled = true
audit_log_backend = "postgresql" # "postgresql" | "syslog" | "file"

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 = true

FraiseQL works with PostgreSQL RLS policies. Use inject= to pass JWT claims, then reference them in your RLS policy function:

-- Enable RLS on the table
ALTER TABLE tb_post ENABLE ROW LEVEL SECURITY;
-- Users can only see their own posts
CREATE POLICY post_owner_policy ON tb_post
FOR ALL
USING (fk_user = current_user_pk());
-- Helper function
CREATE FUNCTION current_user_pk() RETURNS BIGINT AS $$
BEGIN
RETURN current_setting('app.current_user_pk')::BIGINT;
END;
$$ LANGUAGE plpgsql;

  • JWT secrets are 256+ bits (openssl rand -hex 32)
  • Tokens expire appropriately (30 min – 1 hour)
  • Failed login rate limiting enabled
  • All mutations require authentication
  • Sensitive fields protected by requires_scope
  • Row-level filtering via inject= or PostgreSQL RLS
  • [security.error_sanitization] enabled in production
  • Stack traces not exposed to clients
  • TLS at load balancer or via [server.tls]
  • CORS configured via [server.cors] (not wildcard)
  • Rate limiting enabled via [security.rate_limiting]
  • [security.state_encryption] enabled
  • STATE_ENCRYPTION_KEY env var set in all environments where enabled = true
  • enabled = 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
}
}]
}

  1. Test field-level authorization:

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

  2. Test admin-only query with regular token:

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

  3. Test expired token:

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

  4. Test row-level filtering:

    Terminal window
    # User A's token — should only return User A's posts
    curl -X POST http://localhost:8080/graphql \
    -H "Authorization: Bearer $USER_A_TOKEN" \
    -d '{"query": "{ myPosts { title } }"}'