If you run a store that sells gift cards, game keys, or mobile top-ups, your database holds an asset class that is harder to protect than passwords: redemption codes. Each one is bearer money. Steal a password hash and an attacker still has to crack it; steal a plaintext redemption code and the money is gone in seconds, with no chargeback possible.
This is a problem we think about constantly at Kadokey, where instant email delivery of digital codes is the entire product. Here is the pattern used to store high-value secrets at scale: envelope encryption.
Why hashing is not enough
For passwords, bcrypt or Argon2 is the answer because the server never needs the original value back. Redemption codes are different: at dispatch time, you must recover the exact original value to deliver it to the buyer. Encryption -- reversible with the right key -- is mandatory, not optional.
But "encrypt the column" hides the real questions: where does the key live? How do you rotate it without re-encrypting everything at once? What happens when someone with database access runs SELECT * FROM codes? That is where envelope encryption earns its keep.
Envelope encryption in 60 seconds
Instead of one master key encrypting everything directly, you use two layers:
- Data encryption keys (DEKs) -- one per code batch. Each DEK encrypts the actual redemption codes with AES-256-GCM.
- A key encryption key (KEK) -- stored in a managed KMS (AWS KMS, GCP KMS, or HashiCorp Vault), never in your database or codebase. It encrypts ("wraps") the DEKs.
The wrapped DEKs live next to the data. To read a code: ask KMS to unwrap the DEK, decrypt the code in memory, deliver it, then drop it from memory. The database alone is never enough to recover a single code.
Designing the vault table
Keep the schema boring:
-
code_id,batch_id -
ciphertext-- the AES-GCM output -
wrapped_dek-- the DEK encrypted by the KEK -
dek_key_id/kek_version-- which KEK version wrapped this DEK -
nonce,aad-- GCM parameters; bind the AAD to thecode_idso a ciphertext cannot be transplanted onto another row
Never store a DEK in plaintext anywhere. Never log a decrypted code. The dispatch worker is the only service with KMS decrypt permission, and its IAM role should be the narrowest in the system.
Rotation without the big bang
KEK rotation is the part teams get wrong. With envelope encryption, rotating the KEK does not require re-encrypting every code. You only re-wrap the DEKs: unwrap each DEK with the old KEK version, wrap it with the new one, update the row. KMS keeps old key versions available, so in-flight dispatches keep working during the migration. A nightly job can re-wrap batches lazily, and you can prove rotation completed by auditing kek_version across rows.
DEK rotation is equally cheap: generate a new DEK for the next batch of codes from a supplier. Old batches keep their old DEKs. Blast radius stays bounded by batch.
Decrypt only at the moment of dispatch
The strongest control is temporal: a code should exist in plaintext for seconds, not months. The flow:
- The order is paid and fraud-screened.
- The dispatch worker claims one row (
status = 'reserved'), unwraps the DEK via KMS, and decrypts in memory. - The plaintext goes straight into the delivery email -- never back into the database, never into logs (mask it:
XXXX-XXXX-1234). - The row flips to
delivered. If delivery fails, a separate recovery flow re-runs dispatch rather than caching plaintext "just in case."
This is the architecture that makes instant delivery safe: automation handles the secret, humans never see it.
Alert on the things that matter
Encryption is table stakes; detection is what saves you. Alert on:
- KMS
Decryptcalls from outside the dispatch role - A spike in unwrap calls not matched by order volume (a bulk exfiltration attempt)
- Interactive
SELECTqueries against the vault table - Failed GCM authentication tags -- someone is tampering with ciphertext
Pair this with append-only audit logs of every unwrap event: who, which batch, which order ID. If a code is ever reported stolen, you can trace exactly when it was decrypted and for which order.
A minimal sketch
# dispatch_worker.py
dek = kms.decrypt(
CiphertextBlob=row.wrapped_dek,
KeyId="alias/code-vault-kek",
)["Plaintext"]
aesgcm = AESGCM(dek)
code = aesgcm.decrypt(
nonce=row.nonce,
data=row.ciphertext,
associated_data=row.code_id.encode(),
)
deliver(order.email, code.decode())
secure_zero(dek)
secure_zero(code)
Ten lines -- but the guarantees come from everything around them: IAM scoping, AAD binding, rotation jobs, and audit trails.
The takeaway
If your product is digital codes delivered by email, your vault design is your product's trust model. Envelope encryption gives you per-batch blast-radius limits, painless key rotation, and a clean story for auditors -- without exotic infrastructure. Start with a managed KMS, one DEK per supplier batch, AAD-bound ciphertext, and a dispatch worker that forgets secrets the moment it delivers them.
Curious how this looks in a production storefront? Kadokey's catalog runs on exactly this kind of pipeline -- every gift card, game code, and top-up voucher is encrypted at rest and decrypted only at the instant of delivery.
Top comments (0)