# 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.

Source: https://www.bluewise.fr/aes-gcm-aad-ciphertext-swapping  
Author: Marc Morel  
Published: 2026-10-21  
Category: Engineering

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*:

| Input | Secret? | Stored? | Role |
| --- | --- | --- | --- |
| Key | Yes | No: KMS, HSM or secret store | Everything depends on it |
| Nonce, 96 bits | No | Yes, next to the ciphertext | Must never repeat under the same key |
| Plaintext | Yes | No | The value you protect |
| Associated data (AAD) | No | Usually not: recomputed from context | Bound 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:

```sql
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:

```go
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:

```sql
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.

*[Figure: 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:

```go
// 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:

| Stack | Where the AAD goes |
| --- | --- |
| Go | `additionalData` in `Seal` / `Open` |
| Java (JCA) | `cipher.updateAAD(aad)` |
| Python (`cryptography`) | `associated_data` in `AESGCM.encrypt` / `decrypt` |
| Node.js | `setAAD(aad)` |
| .NET | `associatedData` in `AesGcm.Encrypt` / `Decrypt` |
| PHP | `$aad` in `openssl_encrypt`, or libsodium's `$additional_data` |
| Google Tink | `associatedData` in `Aead.encrypt` / `decrypt` |
| AWS KMS | the *encryption context* |
| Google Cloud KMS | `additionalAuthenticatedData` |

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:

```go
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](https://www.bluewise.fr/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.

| Attack | One key, no AAD | One key + AAD | Per-tenant key + AAD | Per-customer key + AAD + narrow decryption |
| --- | --- | --- | --- | --- |
| Move a value between customers | Succeeds | Blocked | Blocked | Blocked |
| Move a value across tenants | Succeeds | Blocked | Blocked twice | Blocked twice |
| Roll a cell back | Succeeds | Succeeds | Succeeds | Detectable via the audit trail |
| Key leaked from one instance | All data | All data | One tenant | Keys that instance unwrapped |
| Erase one customer, backups included | No | No | No | Destroy 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.
