Security
Security — Authentication and RBAC
FraiseQL provides transparent field-level encryption for sensitive data, with automatic key rotation and compliance features.
Field-level encryption protects sensitive data at rest. The application layer sees plaintext, FraiseQL’s encryption layer handles automatic AES-256-GCM encrypt/decrypt transparently, and only ciphertext is stored in the database.
[encryption]enabled = truealgorithm = "AES-256-GCM"
[encryption.keys]# Current encryption key (base64-encoded)current = "${ENCRYPTION_KEY}"
# Previous keys for decryption during rotationprevious = ["${ENCRYPTION_KEY_OLD}"]In your schema, mark fields for encryption:
from typing import Annotatedimport fraiseql
@fraiseql.typeclass User: id: fraiseql.ID name: str
# Encrypted at rest email: Annotated[str, fraiseql.field(encrypted=True)] ssn: Annotated[str, fraiseql.field(encrypted=True)] phone: Annotated[str, fraiseql.field(encrypted=True)]Even though encrypted fields are stored as ciphertext in PostgreSQL, the GraphQL response always contains the decrypted plaintext value. Clients have no awareness of encryption:
query { user(id: "user-123") { id name email ssn }}Response:
{ "data": { "user": { "id": "user-123", "name": "Alice Smith", "email": "alice@example.com", "ssn": "123-45-6789" } }}Meanwhile, the raw database row stores:
id | name | email | ssn------+-------------+------------------------------------------------+------------------------------------------------123 | Alice Smith | \x0102...EncryptedBase64Blob...== | \x0102...AnotherEncryptedBlob...==Choose a key management approach based on your compliance requirements:
| Approach | Setup Complexity | Security | Key Rotation | Best For |
|---|---|---|---|---|
| Local key (env var) | Low | Medium | Manual | Development, small teams |
| HashiCorp Vault | Medium | High | Automatic | Self-hosted production |
| AWS KMS / Azure Key Vault / GCP KMS | Medium | Very High | Automatic | Cloud-native production |
[encryption]enabled = truealgorithm = "AES-256-GCM"
[encryption.keys]current = "${ENCRYPTION_KEY}"Generate a key:
openssl rand -base64 32# Example output: 7K9mN2pQrStUvWxYz0AbCdEfGhIjKlMn4oPqRsTuVwXy=For production, use HashiCorp Vault for key management:
[encryption]enabled = trueprovider = "vault"
[encryption.vault]address = "${VAULT_ADDR}"token = "${VAULT_TOKEN}"secret_path = "secret/data/fraiseql/encryption"key_name = "field-encryption-key"[encryption]enabled = trueprovider = "aws_kms"
[encryption.aws_kms]key_id = "${AWS_KMS_KEY_ID}"region = "${AWS_REGION}"After enabling encryption, confirm that sensitive fields are stored as ciphertext and not plaintext:
# Connect directly to PostgreSQL and inspect the raw columnpsql "$DATABASE_URL" -c \ "SELECT id, email FROM tb_user LIMIT 3;"Expected output (encrypted):
id | email------+-------------------------------------------------- 1 | \x01020000001234...EncryptedBlob...5678== 2 | \x01020000009abc...AnotherBlob...def0== 3 | \x010200000056ef...ThirdBlob...ab12==If you see plaintext email addresses, encryption is not active. Check that:
[encryption] enabled = true is set in fraiseql.tomlENCRYPTION_KEY environment variable is setpgcrypto extension is installed in PostgreSQLYou can also use the built-in verification command:
fraiseql-cli encryption verify# Output:# Checking 3 encrypted field(s) across 1 table(s)...# ✓ tb_user.email: encrypted (AES-256-GCM)# ✓ tb_user.ssn: encrypted (AES-256-GCM)# ✓ tb_user.phone: encrypted (AES-256-GCM)# All fields verified.Regular key rotation limits the exposure window if a key is compromised.
Configure automatic key rotation:
[encryption.rotation]enabled = trueinterval_days = 90notify_before_days = 14
[encryption.rotation.vault]# Vault handles key versioningauto_rotate = trueGenerate a new key and store it in your secrets manager.
Update fraiseql.toml: move the current key to previous and set the new key as current:
[encryption.keys]current = "${ENCRYPTION_KEY_NEW}"previous = ["${ENCRYPTION_KEY_OLD}"]Dry run to validate the rotation without committing changes:
fraiseql-cli encryption rotate --dry-run# Output:# Would re-encrypt 12,450 rows across 3 fields# Estimated time: ~45 seconds# No changes made (dry run)Apply the rotation:
fraiseql-cli encryption rotate --confirm# Output:# Re-encrypting tb_user.email... done (12,450 rows)# Re-encrypting tb_user.ssn... done (12,450 rows)# Re-encrypting tb_user.phone... done (12,450 rows)# Rotation complete. Old key can be removed in 24h.Verify all data decrypts correctly with the new key:
fraiseql-cli encryption verifyRemove the old key from previous after confirming no errors for 24 hours.
FraiseQL does not require a bulk re-encryption job. When a record encrypted with an old key is read, FraiseQL:
This means key rotation completes organically as records are accessed. Use fraiseql-cli encryption rotate --confirm to force-rotate all records at once before removing old keys.
| Field Type | Encryption Support |
|---|---|
String | Full |
Email | Full |
Phone | Full |
JSONB | Full (entire object) |
Int | Partial (stored as string) |
Float | Partial (stored as string) |
Boolean | Not recommended |
Date | Partial (stored as string) |
Encrypted fields cannot be directly searched:
# Will not work - field is encryptedquery { users(where: { email: { _eq: "user@example.com" } }) { id name }}Store a hash alongside encrypted data:
-- In your migrationALTER TABLE tb_user ADD COLUMN email_hash TEXT;CREATE INDEX ON tb_user(email_hash);@fraiseql.typeclass User: email: Annotated[str, fraiseql.field(encrypted=True, blind_index=True)]For exact match queries on encrypted fields:
CREATE TABLE tb_user_email_lookup ( email_hash TEXT PRIMARY KEY, fk_user BIGINT REFERENCES tb_user(pk_user));| Operation | Overhead |
|---|---|
| Encrypt (per field) | ~0.1ms |
| Decrypt (per field) | ~0.1ms |
| Batch (100 rows) | ~5ms |
All encryption operations are logged:
[encryption.audit]enabled = truelog_access = truelog_rotation = trueAudit entries include:
[compliance]profile = "HIPAA" # or "PCI", "SOC2", "GDPR"
[compliance.hipaa]# HIPAA-specific requirementsencryption_required_fields = ["ssn", "medical_record", "diagnosis"]audit_retention_days = 2555 # 7 yearsWhen decryption fails (e.g., wrong key):
{ "errors": [{ "message": "Field decryption failed", "extensions": { "code": "DECRYPTION_ERROR", "field": "email" } }]}FraiseQL automatically tries previous keys:
| Metric | Description |
|---|---|
fraiseql_encryption_ops_total | Encryption operations |
fraiseql_decryption_ops_total | Decryption operations |
fraiseql_encryption_latency_ms | Operation latency |
fraiseql_key_rotation_total | Key rotations performed |
Only encrypt truly sensitive fields:
@fraiseql.typeclass User: id: fraiseql.ID # Don't encrypt - needed for queries name: str # Don't encrypt - low sensitivity email: Annotated[str, fraiseql.field(encrypted=True)] # PII ssn: Annotated[str, fraiseql.field(encrypted=True)] # Highly sensitive created_at: DateTime # Don't encrypt - needed for queries[encryption.rotation]interval_days = 90 # Every 90 daysfraiseql-cli encryption verifyEncryption is transparent to GraphQL clients — they always receive decrypted values:
curl -s http://localhost:8080/graphql \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"query": "{ me { email ssn } }"}'{ "data": { "me": { "email": "alice@example.com", "ssn": null } }}Security
Security — Authentication and RBAC
Deployment
Deployment — Production key management