Skip to content

Field-Level Encryption

FraiseQL provides transparent field-level encryption for sensitive data. Encryption happens entirely in the Rust runtime — no PostgreSQL extensions are required, and GraphQL clients always receive decrypted plaintext.

Field-level encryption in FraiseQL is implemented by the fraiseql-secrets Rust crate using AES-256-GCM authenticated encryption. The encryption layer sits between the GraphQL executor and the database:

  1. On write (mutations): the runtime intercepts field values marked for encryption, encrypts them using AES-256-GCM before they reach the database, and stores the ciphertext as binary data.
  2. On read (queries): the runtime decrypts ciphertext from the database back to plaintext before returning the GraphQL response.

GraphQL clients have no awareness of encryption — they send and receive plaintext values. The ciphertext never appears in a GraphQL response.

Each encrypted value is stored as [12-byte nonce][ciphertext][16-byte GCM tag]. A fresh random nonce is generated for every encryption operation, so encrypting the same plaintext twice always produces different ciphertexts. The GCM authentication tag verifies both integrity and authenticity — any tampering causes decryption to fail.

For additional security, fields can be encrypted with an Additional Authenticated Data (AAD) context string (for example, "user:{id}:field:email:op:insert"). The context is bound into the GCM authentication tag but is not stored in the ciphertext. Decryption requires supplying the identical context, which means a ciphertext cannot be moved from one record to another and decrypted successfully.

Encryption keys are never stored in fraiseql.toml. They are fetched at runtime from the configured secrets backend.

FraiseQL supports three secrets backends, selected at server startup:

BackendUse Case
Environment variablesDevelopment and simple deployments
Local filesTesting, Docker secrets, Kubernetes secret volumes
HashiCorp VaultProduction — dynamic secrets, automatic lease renewal

AES-256-GCM requires exactly 32 bytes (256 bits). Generate a key with:

Terminal window
# Generate a 32-byte random key and base64-encode it for storage
openssl rand -hex 32
# Example output: a3f2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5e6f7a8b9c0d1e2

Store the raw 32-byte value (not base64) as an environment variable or Vault secret. The key must be exactly 32 bytes when interpreted as UTF-8 or binary — the runtime validates this at startup and will refuse to start if the key length is wrong.

The simplest approach for development:

Terminal window
# The value must be exactly 32 bytes
export FIELD_ENCRYPTION_KEY_EMAIL="01234567890123456789012345678901"
export FIELD_ENCRYPTION_KEY_PHONE="abcdefghijklmnopqrstuvwxyz012345"

Each field uses its own named key. The key name in the environment corresponds to the key_reference configured in the compiled schema — see Configuring Encrypted Fields below.

For Docker secrets or Kubernetes secret volumes, keys can be stored in files. Each file contains the raw 32-byte key value. The backend reads the file contents at startup and whenever the cache is invalidated.

For production, use Vault to centralize key storage and enable lease-based rotation:

Terminal window
# Write an encryption key to Vault KV2
vault kv put secret/fraiseql/keys/email \
value="01234567890123456789012345678901"
vault kv put secret/fraiseql/keys/phone \
value="abcdefghijklmnopqrstuvwxyz012345"

The Vault backend supports:

  • Token authentication (simple deployments)
  • AppRole authentication (recommended for production — no long-lived tokens)
  • Automatic caching of secrets for 80% of the Vault lease duration
  • Retry with exponential backoff (up to 3 attempts) on transient errors

Encrypted field configuration lives in the compiled schema, not in fraiseql.toml and not in the Python schema decorators. The Python fraiseql.field() decorator does not support an encrypted parameter — field encryption is configured separately as part of the schema compilation pipeline.

Each encrypted field requires a key_reference — the name used to fetch the encryption key from the secrets backend. At server startup, the FieldEncryptionService reads the compiled schema, identifies all fields with encryption config, and maps each field to its key.

Store encrypted fields as TEXT or BYTEA in the base table. The runtime stores base64-encoded ciphertext as a text value. Use the trinity pattern for all base tables:

CREATE TABLE tb_user (
pk_user BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
identifier TEXT UNIQUE NOT NULL, -- e.g., email hash or username
-- Encrypted fields: stored as base64-encoded AES-256-GCM ciphertext
email TEXT NOT NULL, -- plaintext never stored here
phone TEXT, -- nullable encrypted field
ssn TEXT,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_tb_user_id ON tb_user(id);

The read view exposes these fields normally — decryption happens in the Rust runtime before the response is sent, so the view returns the ciphertext and the runtime decrypts it:

CREATE VIEW v_user AS
SELECT
u.id,
jsonb_build_object(
'id', u.id::text,
'identifier', u.identifier,
'name', u.name,
'email', u.email, -- ciphertext; runtime decrypts before response
'phone', u.phone,
'ssn', u.ssn,
'created_at', u.created_at
) AS data
FROM tb_user u;

The mutation function receives plaintext from the GraphQL client. The runtime encrypts the values before passing them to the function, so the function always receives ciphertext:

CREATE FUNCTION fn_create_user(
p_identifier TEXT,
p_name TEXT,
p_email TEXT, -- receives ciphertext from runtime
p_phone TEXT,
p_ssn TEXT
) RETURNS mutation_response AS $$
DECLARE
v_user_id UUID := gen_random_uuid();
v_pk BIGINT;
BEGIN
INSERT INTO tb_user (id, identifier, name, email, phone, ssn)
VALUES (v_user_id, p_identifier, p_name, p_email, p_phone, p_ssn)
RETURNING pk_user INTO v_pk;
RETURN ROW(
'success', NULL, v_user_id, 'User',
(SELECT data FROM v_user WHERE id = v_user_id),
NULL, NULL, NULL
)::mutation_response;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Encryption is completely transparent to GraphQL clients. Clients send plaintext and receive plaintext:

mutation {
createUser(identifier: "alice", name: "Alice Smith", email: "alice@example.com", ssn: "123-45-6789") {
id
name
email
ssn
}
}

Response:

{
"data": {
"createUser": {
"id": "a1b2c3d4-...",
"name": "Alice Smith",
"email": "alice@example.com",
"ssn": "123-45-6789"
}
}
}

Meanwhile, the raw database row stores only ciphertext:

pk_user | id | identifier | email | ssn
--------+----------+------------+--------------------------------------------+--------------------------------------------
1 | a1b2c3.. | alice | dGhpcyBpcyBiYXNlNjQgZW5jb2RlZCBjaXBoZXJ0 | bm9uY2UrcGF5bG9hZCthdXRodGFn

If you query the base table directly in PostgreSQL, you see only ciphertext. The runtime is the only component that holds the key and can decrypt.

Encrypted fields cannot be used in WHERE clauses because the stored ciphertext is different for each encryption (random nonces). You have two options for enabling lookup:

Add a deterministic hash of the plaintext as a separate column. Use a keyed hash (HMAC-SHA256 with a separate secret) rather than a plain SHA-256 to prevent preimage attacks:

ALTER TABLE tb_user ADD COLUMN email_hmac TEXT;
CREATE INDEX idx_tb_user_email_hmac ON tb_user(email_hmac);

The application computes HMAC-SHA256(email, hmac_key) before inserting. Lookups search on the HMAC column, not the encrypted column.

For exact-match lookups on an encrypted field, maintain a separate lookup table using the trinity pattern:

CREATE TABLE tb_user_email_lookup (
pk_user_email_lookup BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
identifier TEXT UNIQUE NOT NULL, -- the HMAC of the email
fk_user BIGINT NOT NULL REFERENCES tb_user(pk_user)
);
CREATE INDEX idx_tb_user_email_lookup_fk ON tb_user_email_lookup(fk_user);

identifier holds the HMAC of the email address. To look up a user by email, compute the HMAC client-side and query on identifier.

Key rotation is handled at the secrets backend level:

  • Vault: rotate the KV2 secret. The runtime cache is invalidated automatically (cache TTL is 80% of the Vault lease). New encryptions use the new key. Existing ciphertext encrypted with the old key remains readable until the cache clears and a fresh key is fetched — there is no built-in re-encryption of historical rows.
  • Environment variables: update the environment variable and restart the server. Existing rows encrypted with the old key will fail to decrypt after the restart.

When decryption fails (wrong key, corrupted data, or context mismatch), the runtime returns a GraphQL error:

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

The authentication tag verification in AES-256-GCM means that any modification to the ciphertext — including truncation, bit flipping, or key mismatch — causes an explicit failure rather than silently returning garbage data.

AES-256-GCM is implemented in Rust with hardware acceleration (AES-NI) where available. Typical overhead:

OperationOverhead
Encrypt (per field)~0.1 ms
Decrypt (per field)~0.1 ms

Cipher instances are cached per field in the DatabaseFieldAdapter — the expensive key-schedule setup runs once per field at startup (or after cache invalidation), not on every request.

Recommendations:

  • Only encrypt fields that genuinely contain sensitive data (PII, credentials, financial identifiers). Adding encryption to non-sensitive fields adds latency without a security benefit.
  • Use the Arc<FieldEncryption> sharing model — the runtime already does this, so no additional action is needed from schema authors.
  • If a field is frequently accessed, the cipher cache means decryption adds no key-fetch overhead after the first call.

Field-level encryption and field-level access control (fraiseql.field(requires_scope=...)) are orthogonal features and can be combined. Access control is enforced before decryption: if a caller lacks the required scope, the field is rejected or masked and decryption is never attempted.

Example: a ssn field that is both encrypted at rest and requires a specific scope to read:

from typing import Annotated
import fraiseql
@fraiseql.type
class User:
id: fraiseql.ID
name: str
email: str # encrypted at rest (configured in compiled schema)
# Encrypted at rest AND requires scope to read
ssn: Annotated[str | None, fraiseql.field(
requires_scope="pii:read_ssn",
on_deny="mask"
)]

The encrypted=True parameter does not exist in fraiseql.field(). The encrypted-at-rest configuration is separate from the access-control annotation and is set in the compiled schema, not in the Python decorator.

Security

Security — Authentication and field-level access control

Deployment

Deployment — Production key management