---
key: "aead-record-design"
title: "AEAD records: AES-GCM nonces, associated data and key limits"
url: "https://softwaresecurity.com/domains/identity-crypto/encryption/authenticated-record-design/"
api: "https://softwaresecurity.com/api/v1/articles/aead-record-design.json"
domain: "identity-crypto"
topic: "Identity \u0026 cryptography"
type: "Guide"
identifiers: ["CWE-323","CWE-327","CWE-353","A04:2025","V11.2.1","V11.3.2","V11.3.3","V11.3.4"]
tags: ["Encryption"]
stages: ["design","build"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# AEAD records: AES-GCM nonces, associated data and key limits

Build an encrypted record whose tag binds ciphertext to the intended tenant, record, purpose and format, keep nonces unique within each key’s usage limit, and verify the design with actual AES-GCM.

Canonical URL: https://softwaresecurity.com/domains/identity-crypto/encryption/authenticated-record-design/

## In short

Use AES-256-GCM or ChaCha20-Poly1305 through a maintained library, never reuse a nonce under the same key, and authenticate the record’s version, tenant, ID and purpose as associated data rebuilt from the trusted destination record. With 96-bit random nonces NIST SP 800-38D allows at most 2^32 encryptions per key, so give each record its own data key or rotate by count, or use XChaCha20-Poly1305 or AES-GCM-SIV.

- The tag authenticates only the bytes you pass in: put the destination’s version, tenant, record ID and purpose in the associated data.
- Rebuild the associated data from the record you looked up and authorized, never from fields inside the envelope.
- A repeated AES-GCM nonce under one key leaks the XOR of plaintexts and lets an attacker forge messages.
- Random 96-bit nonces: at most 2^32 encryptions per key across every writer; counters need durable allocation.
- For random nonces at high volume use XChaCha20-Poly1305 (192-bit nonce) or AES-GCM-SIV, which does not fail catastrophically on a repeat.
- Keep the full 128-bit tag and release no plaintext until the tag verifies.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Algorithm | AES-256-GCM or ChaCha20-Poly1305 | XChaCha20-Poly1305 or AES-GCM-SIV when nonces are random and volume is high. | Tink AEAD documentation; RFC 8452 |
| Key | 256 bits from a CSPRNG or KMS | AES-128 remains approved; 256-bit keys add margin and meet CNSA 2.0. | NIST IR 8547 (initial public draft) |
| Nonce (AES-GCM) | 96 bits, never repeated under one key | Random nonces: at most 2^32 encryptions per key, counted across all writers. | NIST SP 800-38D; NIST revision notice, January 2025 |
| Tag | 128 bits | NIST’s June 2026 revision plan removes GCM tags shorter than 96 bits. | NIST SP 800-38D Rev. 1 pre-draft call, June 2026 |
| Message size (AES-GCM) | At most 2^39 − 256 bits (about 64 GiB) per encryption | Split larger objects into chunks with their own nonces and authenticated chunk numbers. | NIST SP 800-38D |
| Associated data | Version, tenant, record ID and purpose, unambiguously encoded | For example a JSON array or length-prefixed fields; never a delimiter-joined string. | RFC 5116 |

## Verify it

1. Encrypt the same plaintext twice under one key and confirm the IVs and ciphertexts differ.
2. Decrypt a valid envelope against another tenant, record ID or purpose and confirm it fails without returning plaintext.
3. Flip one ciphertext or tag bit and confirm decryption fails before any parsing or side effect.
4. Find where the per-key encryption count lives and confirm rotation happens well before 2^32 random-nonce encryptions.
5. In the encrypted-records project, run `node --test exercise.test.mjs` and confirm all ten tests pass after the repair.

## What you will learn

- Explain what associated data authenticates and leaves visible.
- Design an unambiguous, versioned record context.
- Repair and verify actual AES-GCM context binding.

Audience: Developers who encrypt application records, and reviewers who check what the authentication tag actually binds.

Before you start:

- Distinguish plaintext, ciphertext, authentication tag, nonce and associated data.

Time: About 20 minutes: 6 to read and 14 for the exercise

## Authenticity applies to the inputs you actually authenticate

Authenticated encryption with associated data takes a key, nonce, plaintext, and associated data, and returns ciphertext with an authentication tag. Decryption takes those inputs and either returns the original plaintext or fails. Associated data is authenticated but not encrypted, so it is suitable for context that can remain visible.

The tag authenticates only the bytes given to the construction. If every invoice uses associated data equal to the word invoice, a valid encrypted invoice can potentially be copied into another invoice slot under the same key. The tag still verifies because neither the key nor the authenticated context changed. Cryptography cannot infer the database row where the application intended the ciphertext to belong.

Bind the record’s purpose, format version, tenant, and stable record identifier using values obtained from the trusted destination context. If a copied envelope carries its own tenant and the decryptor blindly uses that tenant as associated data, the attacker has moved both the ciphertext and its claimed context together. Reconstruct expected context from the authorized record lookup.

## AES-GCM or ChaCha20-Poly1305: which should a record use?

Either: both are AEADs with 256-bit keys, 96-bit nonces and 128-bit tags, and both handle associated data the same way, so the record design on this page applies to both. Use AES-256-GCM on servers and phones with AES instructions or when you need a FIPS-approved algorithm, and ChaCha20-Poly1305 where code may run without AES hardware. The choice that matters more is how nonces are produced: if many writers draw random nonces under one key, prefer XChaCha20-Poly1305 or AES-GCM-SIV, or give each record its own data key. The comparison table in the cryptography quick reference at /domains/identity-crypto/encryption/crypto-quick-reference/ lists speed, approval status, nonce limits and message-size limits side by side.

## A versioned envelope needs one unambiguous interpretation

A practical envelope may contain format version, algorithm identifier or profile, key identifier, nonce, ciphertext, and tag. Some libraries combine ciphertext and tag. Decide which representation you store and impose length limits before allocating or decoding large inputs. Unknown versions should fail explicitly rather than being guessed from parser success.

For this project, the authenticated context is the ordered JSON array `[1, tenant, id, "invoice"]`. The fixed array order and JSON string escaping preserve field boundaries, including tenant or ID strings containing colons. Validate that version is the number 1, purpose is exactly `invoice`, and tenant and ID are nonempty strings before encoding.

The envelope key identifier may help select from an allowed local key set, but is not permission to retrieve arbitrary keys or cross tenants. Authenticated encryption does not replace the authorization that decides whether a caller can read this record. It also does not prevent replay of an older valid version; use trusted revision state when rollback matters. The Node.js example below implements this format with Web Crypto: it rejects unsupported versions and purposes, keeps the authentication tag at 128 bits and counts encryptions per key.

```javascript
// AES-256-GCM records with a 96-bit random IV, a 128-bit tag and the record's context as
// associated data. Random IVs allow at most 2^32 encryptions per key (NIST SP 800-38D).
const LIMIT = 2 ** 32;
const encoder = new TextEncoder();

function context({version, tenant, id, purpose}) {
  if (version !== 1 || purpose !== 'invoice') throw new Error('unsupported record');
  if (typeof tenant !== 'string' || !tenant || typeof id !== 'string' || !id)
    throw new Error('invalid record identifiers');
  return encoder.encode(JSON.stringify([version, tenant, id, purpose])); // keeps field boundaries
}

export async function encryptRecord(keyState, record, plaintext) {
  if (++keyState.uses > LIMIT) throw new Error('key exhausted: rotate the data key');
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const params = {name: 'AES-GCM', iv, additionalData: context(record), tagLength: 128};
  const ciphertext = new Uint8Array(await crypto.subtle.encrypt(params, keyState.key, plaintext));
  return {v: 1, kid: keyState.id, iv, ciphertext}; // ciphertext ends with the tag
}

// `destination` is the record you looked up and authorized; never the envelope's own claims.
export async function decryptRecord(keyState, destination, envelope) {
  if (envelope.v !== 1 || envelope.kid !== keyState.id) throw new Error('unknown key or format');
  const params = {name: 'AES-GCM', iv: envelope.iv, additionalData: context(destination), tagLength: 128};
  return new Uint8Array(await crypto.subtle.decrypt(params, keyState.key, envelope.ciphertext));
}

```

## Uniqueness belongs to the key and every writer using it

For AES-GCM, a nonce repeated under the same key is catastrophic: the two ciphertexts reveal the XOR of their plaintexts, and the repetition lets an attacker recover the GHASH authentication key and forge messages. NIST SP 800-38D limits a key to 2^32 encryptions when nonces are 96-bit random values, a limit NIST restated in its January 2025 call to revise the standard. Counters avoid that probability bound but need durable allocation across processes, restarts and restored snapshots; neither “random” nor “counter” is a complete lifecycle design by itself.

Stay inside the bound by design. Give each record or file its own data key through envelope encryption (/domains/identity-crypto/key-management-pki/envelope-encryption-rotation/), or count encryptions per key across every writer and rotate well before 2^32. The downloadable project uses fresh 12-byte random nonces with transient test keys, which is safe at its scale; it does not implement a fleet-wide counter or key budget. The AES-GCM nonce lab at /labs/nonces/ practices the counter design.

Where random nonces at high volume are unavoidable, choose an AEAD designed for them. XChaCha20-Poly1305 has a 192-bit nonce that libsodium describes as safe to choose at random for a practically unlimited number of messages, and AES-GCM-SIV (RFC 8452) does not fail catastrophically if a nonce repeats. A high-level library such as Tink, libsodium or the AWS Encryption SDK chooses nonces and key derivation for you.

Keep the full 128-bit tag: NIST’s June 2026 revision plan removes GCM tags shorter than 96 bits. AES-GCM also does not commit to its key, so a crafted ciphertext can decrypt under two different keys; when several keys or recipients could open the same ciphertext, use a key-committing scheme such as the AWS Encryption SDK’s default algorithm suite.

Do not release partially decrypted bytes before tag verification succeeds. Some streaming interfaces make provisional plaintext available, so the application must buffer or quarantine it until authentication completes. A JSON parser throwing an error after unauthenticated plaintext is used is not an integrity check.

## Run the real record-binding repair

Download the “Bind ciphertext to the correct record” project from the local project section below and extract it. With a supported Node.js release (22 or later), run `node --test exercise.test.mjs`; no package installation is required. The supplied `records.mjs` performs actual Web Crypto AES-GCM with temporary keys. The learner repairs `recordContext` in `exercise.mjs`.

The initial implementation authenticates only `invoice`. Consequently, the ordinary round trip and ciphertext-tamper checks can work while context-transplant and validation tests fail. This is why a successful round trip plus one modified-byte test is incomplete evidence for record binding.

After repair, all ten tests must pass. They cover the ordinary invoice, modified ciphertext, wrong tenant, wrong record ID, wrong purpose, wrong version, different key, separator-containing fields, unsupported format/purpose on encryption, and invalid identifiers. These are real encrypt/decrypt checks; production authorization, key persistence, and nonce durability remain outside the project.

## Guided practice

Repair the project’s `recordContext` function, then explain why each rejection depends on trusted destination context.

### Supplied material

```text
Project: encrypted-records, downloaded from /labs/#project-encrypted-records
Runtime: a supported Node.js release (22 or later); no packages.
Command: node --test exercise.test.mjs
Initial function: recordContext(record) returns UTF8("invoice").
Expected supported record: {version:1,tenant:"acme",id:"inv-42",purpose:"invoice"}.
```

### 1. Observe the vulnerable behavior

Run the supplied test command and note which security boundary a valid transplant crosses.

Worked answer: A constant context permits valid ciphertext to authenticate in another context under the same key. The suite reports failing context-binding and input-validation expectations; tampering can still be rejected.

### 2. Repair encoding and validation

Require numeric version 1, purpose `invoice`, and nonempty string tenant and ID; encode `JSON.stringify([version,tenant,id,purpose])` as UTF-8.

Worked answer: The tuple represents every required binding and preserves field boundaries. Unsupported version/purpose and invalid identifiers throw before encryption. Do not change the tests.

### 3. Verify and explain

Rerun the command and compare `tenant="a:b", id="c"` with `tenant="a", id="b:c"`.

Worked answer: All ten tests pass. The encoded tuples differ, so delimiter-containing fields cannot collide. A copied envelope fails when opened with the different trusted destination context.

Deliverable: The repaired `exercise.mjs`, passing ten-test output, and a short explanation of why context must come from the destination.

Scope: This project performs real AES-GCM with transient keys. It does not implement persistent key management, production nonce allocation, replay prevention, or caller authorization.

## Executable practice

- [AES-GCM nonce reuse lab: Fix a nonce allocator that breaks after restart](https://softwaresecurity.com/labs/nonces/)
- [Bind ciphertext to the correct record: project ZIP](https://softwaresecurity.com/downloads/projects/encrypted-records-0959d731656638c9.zip) — Node.js 22+ · no dependencies

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [RFC 5116: Authenticated encryption interface](https://www.rfc-editor.org/rfc/rfc5116.html). January 2008.
- [NIST SP 800-38D: GCM](https://csrc.nist.gov/pubs/sp/800/38/d/final). November 2007.
- [NIST: SP 800-38D revision and the 2^32 random-nonce limit (January 2025)](https://csrc.nist.gov/pubs/sp/800/38/d/r1/iprd)
- [NIST: SP 800-38D Rev. 1 second pre-draft call, tag lengths (June 2026)](https://csrc.nist.gov/pubs/sp/800/38/d/r1/2prd)
- [RFC 8452: AES-GCM-SIV](https://www.rfc-editor.org/rfc/rfc8452.html). April 2019.
- [libsodium: XChaCha20-Poly1305](https://doc.libsodium.org/secret-key_cryptography/aead/chacha20-poly1305/xchacha20-poly1305_construction)
- [OWASP Cryptographic Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html)