Skip to content

PII Encryption

AMSDAL provides built-in field-level encryption for Personally Identifiable Information (PII). Mark sensitive fields with the PIIStr type, and AMSDAL automatically encrypts values before writing to the database and decrypts them on demand when reading.

Data is encrypted using AES-256-GCM with envelope encryption backed by AWS KMS — the same approach used by AWS services themselves.

from amsdal.models import Model, PIIStr
from pydantic import Field

class Customer(Model):
    name: str
    email: PIIStr = Field(title='email')
    phone: PIIStr | None = Field(default=None, title='phone')

With this definition:

  • email and phone are stored encrypted in the database
  • When reloaded from the database, they come back as encrypted ciphertext by default
  • To get plaintext values back, explicitly request decryption via QuerySet or REST API

Tip

PIIStr, EncryptedStr, and get_pii_fields are re-exported from amsdal.models for convenience. The fully-qualified path amsdal_models.classes.fields.pii also works.

Live instance vs database read

The live Customer object you constructed never holds ciphertext: encryption produces ciphertext only in the data sent to the database, the in-memory instance is left untouched. So immediately after save(), accessing customer.email still returns the original plaintext on that same instance.

Encrypted ciphertext is what comes back from a fresh QuerySet read — i.e. when the row is loaded from the database. Those values come back wrapped in EncryptedStr, a str subclass that masks itself in repr() / str() / format() to prevent accidental log leakage. Use the .ciphertext property to access the raw encrypted bytes if you need them.


How It Works

┌─────────────┐      ┌──────────────────┐      ┌──────────────────┐
│  Your App   │ ──── │  AMSDAL          │ ──── │  PII Cryptor     │
│  (Model)    │      │  (Framework)     │      │  (Service)       │
└─────────────┘      └──────────────────┘      └──────────────────┘
                                                        │
                                                ┌───────┴────────┐
                                                │   AWS KMS      │
                                                └────────────────┘
  1. You define model fields using PIIStr
  2. On save, AMSDAL detects PII fields and sends their values to the PII Cryptor service for encryption before writing to the database
  3. On read, encrypted values are returned as-is unless you explicitly request decryption
  4. The PII Cryptor service uses envelope encryption: AWS KMS generates a data encryption key, the service encrypts values with AES-256-GCM, and only the encrypted key is stored alongside the ciphertext

This means plaintext PII never exists in your database — even if the database is compromised, the data remains protected.


Defining PII Fields

Use PIIStr as the field type for any string field containing sensitive data:

from amsdal.models import Model, PIIStr
from pydantic import Field

class User(Model):
    username: str
    email: PIIStr = Field(title='email')
    ssn: PIIStr | None = Field(default=None, title='ssn')

PIIStr is a regular str with metadata that tells AMSDAL to encrypt/decrypt it. You can use it anywhere you'd use str — required fields, optional fields, with defaults.

Detecting PII Fields Programmatically

from amsdal.models import get_pii_fields

fields = get_pii_fields(User)
# ['email', 'ssn']

Encryption and Decryption

Encryption — Automatic on Save

PII fields are encrypted automatically when saving objects. No extra code needed:

user = User(username='alice', email='alice@example.com', ssn='123-45-6789')
await User.objects.bulk_acreate([user])
# email and ssn are encrypted before hitting the database

In the database, the values look like:

#01#<encrypted_key>:<iv>:<ciphertext>:<tag>#10#

Decryption — Opt-in on Read

By default, PII fields return encrypted ciphertext. To get plaintext values, chain .decrypt_pii() on the QuerySet:

# Encrypted values (default)
users = await User.objects.aexecute()
print(users[0].email)  # #01#abc123:def456:ghi789:jkl000#10#

# Decrypted values
users = await User.objects.decrypt_pii().aexecute()
print(users[0].email)  # alice@example.com

This is intentional — decryption requires a call to the PII Cryptor service and ultimately to AWS KMS, so it only happens when you explicitly need the plaintext.

Note

decrypt_pii() is a regular chainable queryset method — it works in both sync and async mode (qs.decrypt_pii().aexecute() is valid). There is no adecrypt_pii async sibling; the chaining happens before the terminal aexecute() call where the actual decryption batch fires.


Querying by PII Fields

Random-IV ciphertext is non-deterministic — encrypting the same plaintext twice yields two different ciphertexts. As a result, filtering, excluding, or ordering by a PIIStr field never matches anything. AMSDAL emits a WARNING in the logger when it detects such a query:

User.objects.filter(email='alice@example.com')   # logs warning, returns empty
User.objects.exclude(email='alice@example.com')  # logs warning
User.objects.order_by('email')                   # logs warning

The walker also follows FK paths — filter(profile__email=...) is detected the same way.

If you need to look up records by PII, store a separate non-PII identifier (e.g. a hashed lookup key in a regular str field) and filter by that.

PII fields cannot be primary keys or unique constraints

A PIIStr field declared in __primary_key__ or any UniqueConstraint raises PIIInvalidFieldUsageError at class definition time. Random-IV ciphertext makes both checks vacuous — a fresh ciphertext on every save means the same value would never identify the same row, and uniqueness can never be detected.


REST API

All object endpoints support the decrypt_pii query parameter:

GET /api/objects/?class_name=User&decrypt_pii=true
GET /api/objects/{address}/?decrypt_pii=true
POST /api/objects/?decrypt_pii=true
PATCH /api/objects/{address}/?decrypt_pii=true

When decrypt_pii=true, the response includes decrypted PII field values. When omitted or false, encrypted ciphertext is returned.


Configuration

AMSDAL Cloud

When deploying to AMSDAL Cloud, PII encryption works out of the box — no configuration needed. The AMSDAL_PII_CRYPTOR_BASE_URL and AMSDAL_PII_CRYPTOR_CLIENT_ID are set automatically during deployment.

By default, all deployed applications use shared encryption keys. If you need a dedicated KMS key for your application (e.g. for compliance or data isolation requirements), contact us and we'll provision one for you.

Self-Hosted / Local Development

When running outside AMSDAL Cloud, the cryptor is configured via environment variables:

Variable Description Default
AMSDAL_PII_CRYPTO_SERVICE Import path to the cryptor class to use amsdal.services.pii_cryptor.PIICryptorService
AMSDAL_PII_CRYPTOR_BASE_URL URL of the PII Cryptor service (required by the real cryptor) ""
AMSDAL_PII_CRYPTOR_CLIENT_ID Client identifier for per-client KMS keys (real cryptor) ""

The default PIICryptorService will refuse to start if AMSDAL_PII_CRYPTOR_BASE_URL is empty — failing fast prevents the worst failure mode, where data looks encrypted but isn't.

For local development, point AMSDAL_PII_CRYPTO_SERVICE at the bundled fake:

# .env
AMSDAL_PII_CRYPTO_SERVICE=amsdal.services.fake_crypto.FakeCryptoService

FakeCryptoService wraps values with #01#...#10# markers without real encryption and logs a single WARNING banner on startup so it can never be mistaken for real encryption in logs. Never use FakeCryptoService in production.

amsdal new generates a project-level .env selecting the fake cryptor by default (and .env is gitignored), so a freshly created app runs locally out of the box.

Production deployments use the default PIICryptorService. When deploying to AMSDAL Cloud, AMSDAL_PII_CRYPTOR_BASE_URL is provisioned automatically — nothing is required from the user. For self-hosted deployments, set it explicitly to your PII Cryptor endpoint.

Custom cryptor

Any class implementing encrypt/decrypt/aencrypt/adecrypt (and an argless initialize() that validates its own configuration) can be plugged in via AMSDAL_PII_CRYPTO_SERVICE.


Security Architecture

Envelope Encryption

The PII Cryptor service implements envelope encryption:

  1. Encrypt: AWS KMS generates a unique Data Encryption Key (DEK) per request. Values are encrypted with AES-256-GCM using this DEK. The DEK itself is encrypted by KMS and stored alongside the ciphertext.
  2. Decrypt: The encrypted DEK is sent to KMS for decryption, then used to decrypt the values locally.

This means AWS KMS never sees your actual data — only the encryption keys.

AES-256-GCM

Each value is encrypted with:

  • AES-256 — 256-bit symmetric encryption
  • GCM mode — authenticated encryption that detects tampering
  • Random IV — unique initialization vector per value, even within the same request

Key Management

  • Encryption keys are managed by AWS KMS — you never handle raw key material
  • Supports per-client KMS keys for multi-tenant isolation via the X-CLIENT-ID header
  • AWS KMS handles automatic key rotation (every 365 days), retaining old key versions for decryption

What's Protected

Layer Protection
Database PII stored as encrypted ciphertext
Application Decryption only happens on explicit request
Transport HTTPS between service and KMS
Key storage Keys managed by AWS KMS, never stored in plaintext