· Marc Morel · Engineering · 9 min read

Encryption isn’t enough. Where matters too.

AES-GCM stops anyone from forging or altering your ciphertexts. It does not stop them from moving a valid one somewhere else. A concrete attack, the one parameter that fixes it, a test that keeps it fixed, and what is left to protect once it is.

Encryption isn’t enough. Where matters too.

If your team encrypts sensitive fields with AES-GCM, you made a good choice: it is fast, hardware-accelerated, and authenticated. Change a single bit of a ciphertext and decryption fails loudly.

That word is where a quiet misunderstanding starts. It sounds like “nobody can tamper with my data”. It really means something narrower: nobody without the key can produce a ciphertext that decrypts. Nothing in that promise says the ciphertext is in the right place. Someone who can write to your database doesn’t need to forge anything: you already store millions of valid ciphertexts, ready to be moved around.

A quick check before we go further: open your encryption helper. If its signature is Encrypt(value), with nothing saying what is encrypted or for whom, this post is for you.

What AES-GCM actually promises

AES-GCM is an AEAD scheme, Authenticated Encryption with Associated Data:

InputSecret?Stored?Role
KeyYesNo: KMS, HSM or secret storeEverything depends on it
Nonce, 96 bitsNoYes, next to the ciphertextMust never repeat under the same key
PlaintextYesNoThe value you protect
Associated data (AAD)NoUsually not: recomputed from contextBound into the tag, never encrypted

Decryption needs the same key, nonce and AAD, or the 128-bit tag check fails. Put simply:

A ciphertext decrypts only under the exact key and the exact AAD it was produced with.

Read that again with the AAD left empty. Encrypt everything under one key with no AAD, and every ciphertext is valid everywhere.

The IBAN that changed owners

Picture an ordinary service storing customer IBANs, encrypted in the application, with UUIDv7 primary keys:

CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT uuidv7(), -- PostgreSQL 18+
tenant_id UUID NOT NULL,
iban_enc BYTEA NOT NULL -- nonce || ciphertext || tag
);

The Go code is idiomatic and, as far as AES-GCM goes, correct:

type Cipher struct{ aead cipher.AEAD }
// New expects a 32-byte key (AES-256), in real life unwrapped by a KMS.
func New(key []byte) (*Cipher, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Go 1.24+: Seal generates a random 96-bit nonce and prepends it; Open reads it back.
aead, err := cipher.NewGCMWithRandomNonce(block)
if err != nil {
return nil, err
}
return &Cipher{aead: aead}, nil
}
func (c *Cipher) Encrypt(plaintext []byte) []byte {
return c.aead.Seal(nil, nil, plaintext, nil) // no AAD
}
func (c *Cipher) Decrypt(blob []byte) ([]byte, error) {
return c.aead.Open(nil, nil, blob, nil)
}

Meet two customers of the same tenant: Alice (018e5126-…-8af1) and Mallory (01a06432-…-780e).

Now imagine someone who can write to the table but has never seen the key: an injection limited to UPDATE, a back-office tool with raw edit rights, a compromised ETL job, a tampered backup. Exactly the adversary field-level encryption exists for. One statement is enough:

UPDATE customers
SET iban_enc = (SELECT iban_enc FROM customers
WHERE id = '01a06432-6760-7b90-9f79-70f30255780e') -- Mallory's IBAN
WHERE id = '018e5126-7a60-754f-b7a3-da1c3f128af1'; -- into Alice's row

Nothing alarms. Alice’s next refund goes to Mallory’s IBAN; swap the other way, and Mallory’s account page displays Alice’s. The tag checks out both times.

To be fair, AES-GCM did a lot: the attacker couldn’t choose, alter or truncate an IBAN. Their only move left was moving values that already exist. In payments, that is plenty.

Alice's IBAN is copied into Mallory's row

None of this is new: Google Cloud’s KMS documentation calls it a confused deputy attack. Cryptographers know it well; the teams writing Encrypt() helpers, much less so.

The fix: give every ciphertext an address

AAD states, at encryption time, where a ciphertext belongs. It isn’t secret, but it is mixed into the tag, so decrypting with different AAD fails. We bind each value to its tenant, table, column and row:

// ErrAuthentication: tampered, or not decrypted in its own context. Alert on it.
var ErrAuthentication = errors.New("fieldcrypt: authentication failed")
// Context identifies the exact cell a value belongs to.
type Context struct {
TenantID uuid.UUID
Table string
Column string
RowID uuid.UUID
}
const formatV1 byte = 1
// AAD: a format version, then length-prefixed fields. UUIDs as their 16 raw bytes.
func (c Context) AAD() []byte {
aad := []byte{formatV1}
for _, f := range [][]byte{c.TenantID[:], []byte(c.Table), []byte(c.Column), c.RowID[:]} {
aad = binary.BigEndian.AppendUint32(aad, uint32(len(f)))
aad = append(aad, f...)
}
return aad
}
func (c *Cipher) Encrypt(plaintext []byte, ctx Context) []byte {
return c.aead.Seal(nil, nil, plaintext, ctx.AAD())
}
func (c *Cipher) Decrypt(blob []byte, ctx Context) ([]byte, error) {
plaintext, err := c.aead.Open(nil, nil, blob, ctx.AAD())
if err != nil {
return nil, ErrAuthentication
}
return plaintext, nil
}

Context is required, and the sentinel error lets callers spot a moved value with errors.Is.

Replay the attack. Mallory’s blob was sealed for acme / customers / iban / …780e; decrypting it for Alice rebuilds the AAD with …8af1, and Decrypt returns ErrAuthentication. The blob still moves, but only opens where it was sealed, for the price of a few bytes hashed and nothing extra stored.

Every mainstream AEAD API has this parameter; it’s just optional, so it gets skipped:

StackWhere the AAD goes
GoadditionalData in Seal / Open
Java (JCA)cipher.updateAAD(aad)
Python (cryptography)associated_data in AESGCM.encrypt / decrypt
Node.jssetAAD(aad)
.NETassociatedData in AesGcm.Encrypt / Decrypt
PHP$aad in openssl_encrypt, or libsodium’s $additional_data
Google TinkassociatedData in Aead.encrypt / decrypt
AWS KMSthe encryption context
Google Cloud KMSadditionalAuthenticatedData

If you own an internal crypto library, make Encrypt(value, ctx) the only door.

Six rules for getting AAD right

1. Bind to the question, not the answer. Build the AAD from what the application expects: when refunding …8af1, the context comes from the request, not from a column of the very row the attacker can edit. If they control both the ciphertext and the AAD inputs, they can make them agree.

2. Use immutable identifiers only. UUIDv7 keys and tenant IDs, yes; emails or display names, no, or Alice’s IBAN becomes undecryptable the day she changes her address. Beware identifiers that are immutable only until a table is renamed, a tenant merged or IDs regenerated during a migration: each now implies re-encryption.

3. Encode unambiguously and canonically. A naive tenant + "|" + row lets a value containing | collide with another. Length-prefix every field, start with a format version, and use UUIDs’ 16 raw bytes: one service writing them in upper case or without hyphens is enough to make valid data undecryptable.

4. Keep personal data out. AAD is authenticated, not encrypted; the day it lands in a log line, it’s in clear text.

5. Authenticate the key ID and format version. Each blob should carry a header naming its key and format, included in the AAD. This avoids trial decryption during rotations, which is fragile: AES-GCM is not key-committing, so a ciphertext can be crafted to decrypt under several keys, the root of the 2021 “partitioning oracle” attacks.

6. Treat an authentication failure as a security event. Bug, corruption or swap, it deserves an alert, not a debug log and a blank field.

A test that keeps it fixed

Cryptographic properties regress silently. A table-driven test captures the threat model:

func TestCiphertextIsBoundToItsCell(t *testing.T) {
c := newTestCipher(t)
home := Context{acmeID, "customers", "iban", malloryID}
blob := c.Encrypt([]byte("FR76 1027 8060 0000 0204 5670 142"), home)
if _, err := c.Decrypt(blob, home); err != nil {
t.Fatalf("decrypt in place: %v", err)
}
elsewhere := map[string]Context{
"another customer": {acmeID, "customers", "iban", aliceID},
"another tenant": {globexID, "customers", "iban", malloryID},
"another column": {acmeID, "customers", "phone", malloryID},
}
for name, ctx := range elsewhere {
t.Run(name, func(t *testing.T) {
if _, err := c.Decrypt(blob, ctx); !errors.Is(err, ErrAuthentication) {
t.Fatalf("want ErrAuthentication, got %v", err)
}
})
}
}

Add its big sibling: create two customers through your repository, swap their iban_enc with raw SQL, and assert that loading either fails. That protects the wiring, where rule 1 is won or lost.

What AAD doesn’t fix

Rollback within a cell. Alice’s old and current IBANs share the same AAD, so a value restored from last year’s backup still decrypts. A version counter in the AAD only helps if the attacker can’t roll it back too; an audit trail or CDC reconciliation usually catches this better.

Broken authorisation. If Mallory can call GET /customers/018e5126-…-8af1, the application decrypts Alice’s data legitimately. UUIDv7 is time-ordered and partly predictable; it was never access control.

A leaked key. AAD is public. It limits what an attacker can do without the key, not with it.

How much does one failure cost?

With one key for everything, any key compromise is total. That’s the next post in this series; here is its shape:

  • Envelope encryption: a data encryption key per tenant or customer, wrapped by a key encryption key that never leaves a KMS or HSM. Same instinct as in Three turtles might be enough: a root of trust you can use but not copy.
  • Crypto-shredding: destroy a customer’s key, and every copy of their data becomes unreadable, backups included.
  • A shared nonce budget no more: NIST SP 800-38D caps a key at 2³² random-nonce encryptions, because one repeated nonce leaks plaintext XORs and enables tag forgery. NewGCMWithRandomNonce hides nonce handling, not this limit.
  • Fewer places that can decrypt: if every service can unwrap every key, one compromised service exposes everything.
AttackOne key, no AADOne key + AADPer-tenant key + AADPer-customer key + AAD + narrow decryption
Move a value between customersSucceedsBlockedBlockedBlocked
Move a value across tenantsSucceedsBlockedBlocked twiceBlocked twice
Roll a cell backSucceedsSucceedsSucceedsDetectable via the audit trail
Key leaked from one instanceAll dataAll dataOne tenantKeys that instance unwrapped
Erase one customer, backups includedNoNoNoDestroy their key

The second column costs a few lines of code; the rest costs real engineering, and deserves a deliberate decision.

Migrating an existing system

  1. Version the format: prefix each blob with a format byte and key ID, both in the AAD.
  2. Read old, write new: accept format 0 on read, always write format 1.
  3. Backfill format-0 values in the background.
  4. Delete the format-0 read path, the step everyone forgets. While it exists, an old blob from a backup bypasses your binding. You’re done when it’s gone, not when the backfill hits 100%.

What changes

A checklist for your next design review

  • Does every encrypt call carry a context, with no API to omit it?
  • Is the context built from what the application asked for, not from storage?
  • Are AAD fields immutable, canonically encoded, and free of personal data?
  • Is the key ID authenticated, with no trial decryption?
  • Does an authentication failure raise an alert?
  • Is there a swap test at unit and integration level?
  • Is rollback detectable by something other than the cipher?
  • If one key leaks tomorrow, do you know exactly whose data is exposed?

Authenticated encryption is a promise about who produced a value. AAD makes it a promise about where that value belongs. If your Encrypt still takes a single argument, you have a good first pull request for this week.

Share:
Back