Skip to content

Field-Level Encryption

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 = true
algorithm = "AES-256-GCM"
[encryption.keys]
# Current encryption key (base64-encoded)
current = "${ENCRYPTION_KEY}"
# Previous keys for decryption during rotation
previous = ["${ENCRYPTION_KEY_OLD}"]

In your schema, mark fields for encryption:

from typing import Annotated
import fraiseql
@fraiseql.type
class 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:

ApproachSetup ComplexitySecurityKey RotationBest For
Local key (env var)LowMediumManualDevelopment, small teams
HashiCorp VaultMediumHighAutomaticSelf-hosted production
AWS KMS / Azure Key Vault / GCP KMSMediumVery HighAutomaticCloud-native production
[encryption]
enabled = true
algorithm = "AES-256-GCM"
[encryption.keys]
current = "${ENCRYPTION_KEY}"

Generate a key:

Terminal window
openssl rand -base64 32
# Example output: 7K9mN2pQrStUvWxYz0AbCdEfGhIjKlMn4oPqRsTuVwXy=

For production, use HashiCorp Vault for key management:

[encryption]
enabled = true
provider = "vault"
[encryption.vault]
address = "${VAULT_ADDR}"
token = "${VAULT_TOKEN}"
secret_path = "secret/data/fraiseql/encryption"
key_name = "field-encryption-key"
[encryption]
enabled = true
provider = "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:

Terminal window
# Connect directly to PostgreSQL and inspect the raw column
psql "$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:

  1. [encryption] enabled = true is set in fraiseql.toml
  2. ENCRYPTION_KEY environment variable is set
  3. The pgcrypto extension is installed in PostgreSQL
  4. The server was restarted after configuration changes

You can also use the built-in verification command:

Terminal window
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 = true
interval_days = 90
notify_before_days = 14
[encryption.rotation.vault]
# Vault handles key versioning
auto_rotate = true
  1. Generate a new key and store it in your secrets manager.

  2. 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}"]
  3. Dry run to validate the rotation without committing changes:

    Terminal window
    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)
  4. Apply the rotation:

    Terminal window
    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.
  5. Verify all data decrypts correctly with the new key:

    Terminal window
    fraiseql-cli encryption verify
  6. Remove 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:

  1. Detects the key version in the ciphertext header
  2. Decrypts using the matching previous key
  3. Re-encrypts with the current key
  4. Writes the updated ciphertext back to the database

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 TypeEncryption Support
StringFull
EmailFull
PhoneFull
JSONBFull (entire object)
IntPartial (stored as string)
FloatPartial (stored as string)
BooleanNot recommended
DatePartial (stored as string)

Encrypted fields cannot be directly searched:

# Will not work - field is encrypted
query {
users(where: { email: { _eq: "user@example.com" } }) {
id
name
}
}

Store a hash alongside encrypted data:

-- In your migration
ALTER TABLE tb_user ADD COLUMN email_hash TEXT;
CREATE INDEX ON tb_user(email_hash);
@fraiseql.type
class 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)
);
OperationOverhead
Encrypt (per field)~0.1ms
Decrypt (per field)~0.1ms
Batch (100 rows)~5ms
  1. Batch operations: Encrypt/decrypt in batches
  2. Cache decrypted values: For frequently-accessed data
  3. Minimize encrypted fields: Only encrypt truly sensitive data

All encryption operations are logged:

[encryption.audit]
enabled = true
log_access = true
log_rotation = true

Audit entries include:

  • Field accessed
  • User/service that accessed
  • Timestamp
  • Operation type (encrypt/decrypt)
[compliance]
profile = "HIPAA" # or "PCI", "SOC2", "GDPR"
[compliance.hipaa]
# HIPAA-specific requirements
encryption_required_fields = ["ssn", "medical_record", "diagnosis"]
audit_retention_days = 2555 # 7 years

When decryption fails (e.g., wrong key):

{
"errors": [{
"message": "Field decryption failed",
"extensions": {
"code": "DECRYPTION_ERROR",
"field": "email"
}
}]
}

FraiseQL automatically tries previous keys:

  1. Attempt decrypt with current key
  2. If fails, try previous keys in order
  3. If successful, re-encrypt with current key (lazy migration)
  4. If all fail, return error
MetricDescription
fraiseql_encryption_ops_totalEncryption operations
fraiseql_decryption_ops_totalDecryption operations
fraiseql_encryption_latency_msOperation latency
fraiseql_key_rotation_totalKey rotations performed

Only encrypt truly sensitive fields:

@fraiseql.type
class 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 days
  1. Verify encryption key is correct
  2. Check previous keys are configured
  3. Run verification: fraiseql-cli encryption verify
  1. Profile encryption operations
  2. Check batch sizes
  3. Consider caching frequently-accessed decrypted values
  1. Check Vault connectivity
  2. Verify permissions for key operations
  3. Review audit logs for errors

Encryption is transparent to GraphQL clients — they always receive decrypted values:

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