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.
| 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 |
- Encrypt the same plaintext twice under one key and confirm the IVs and ciphertexts differ.
- Decrypt a valid envelope against another tenant, record ID or purpose and confirm it fails without returning plaintext.
- Flip one ciphertext or tag bit and confirm decryption fails before any parsing or side effect.
- Find where the per-key encryption count lives and confirm rotation happens well before 2^32 random-nonce encryptions.
- In the encrypted-records project, run
node --test exercise.test.mjsand confirm all ten tests pass after the repair.
- Applies to
Application records encrypted with an AEAD. The worked allocator is a policy exercise; production keys and nonce state need durable design.
- Part of
- Learning path: Cryptography without shortcuts (step 3 of 12). Workflows: Fix a finding, Secure a feature
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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.
Before you start
Developers who encrypt application records, and reviewers who check what the authentication tag actually binds.
- Distinguish plaintext, ciphertext, authentication tag, nonce and associated data.
Refresh first: ECB mode: why it leaks patterns and what to use instead
Go to the guided practiceAuthenticated 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.
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 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.
// 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));
}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.
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 scenario material
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.
Expected result and reasoning
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; encodeJSON.stringify([version,tenant,id,purpose])as UTF-8.Expected result and reasoning
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"withtenant="a", id="b:c".Expected result and reasoning
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.
exercise.mjs, passing ten-test output, and a short explanation of why context must come from the destination.This project performs real AES-GCM with transient keys. It does not implement persistent key management, production nonce allocation, replay prevention, or caller authorization.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
AES-GCM nonce reuse lab: Fix a nonce allocator that breaks after restart
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Bind ciphertext to the correct record
Use Web Crypto AES-GCM to reject tampered ciphertext and cross-tenant record transplants. Repair authenticated context binding.
In the extracted folder, run:
node --test exercise.test.mjs10 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
Part of the learning path Cryptography without shortcuts
Real encryption with temporary keys; this exercise does not implement a production key-management service.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- RFC 5116: Authenticated encryption interface. January 2008.
- NIST SP 800-38D: GCM. November 2007.
- NIST: SP 800-38D revision and the 2^32 random-nonce limit (January 2025)
- NIST: SP 800-38D Rev. 1 second pre-draft call, tag lengths (June 2026)
- RFC 8452: AES-GCM-SIV. April 2019.
- libsodium: XChaCha20-Poly1305
- OWASP Cryptographic Storage Cheat Sheet
Reference information
- Tags
- Encryption
- SDLC phase
- DesignDevelopment
- Role
- Developers
- ASVS 5.0.0 requirements
- v5.0.0-11.2.1 · v5.0.0-11.3.2 · v5.0.0-11.3.3 · v5.0.0-11.3.4