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.

FraiseQL verifies JWTs automatically on every request. Configuration is done via environment variables, not TOML — there is no [auth] section in fraiseql.toml.

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"

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 jsonb_build_object(
'id', id,
'title', title,
'body', body
)
FROM tb_post
WHERE 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.


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

Enable the audit log via enterprise flags:

[security.enterprise]
audit_logging_enabled = true
audit_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 = 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 INTEGER AS $$
BEGIN
RETURN current_setting('app.current_user_pk')::INTEGER;
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 } }"}'