AES-GCM in TypeScript: the 12-byte nonce, the tag, and why your ciphertext won't decrypt on the other side
Your Node backend encrypts with AES-256-GCM, the frontend decrypts with Web Crypto, the key and IV match… and crypto.subtle.decrypt throws OperationError. The key isn't missing. The tag is. Node hands it to you separatel
Your Node backend encrypts with AES-256-GCM, the frontend decrypts with Web Crypto, the key and IV match… and crypto.subtle.decrypt throws OperationError. The key isn't missing. The tag is. Node hands it to you separately via getAuthTag(), and Web Crypto expects it appended to the end of the ciphertext.
This post continues the thread of the last two: Buffer vs Uint8Array in TypeScript crypto, where AES-GCM showed up in passing, and HMAC over JSON in TypeScript, where the problem was different bytes under the MAC. Here the problem is bytes that are missing, extra, or repeated. The examples use encloom 2.0.4 (encloom/aes-gcm), but the format is the same as Web Crypto's, so everything applies if you do it by hand too.
Not a compliance guide. About bytes on the wire.
What AES-GCM actually returns
GCM encrypts and authenticates in one pass. It takes a key (16, 24, or 32 bytes), a nonce (also called the IV), the plaintext, and optionally additional authenticated data (AAD). It returns the ciphertext, which is the same length as the plaintext, plus a 16-byte (128-bit) tag.
The practical question is where that tag goes. Web Crypto appends it: ciphertext || tag. encloom/aes-gcm uses exactly that layout.
import { aesGcmEncryptSync, aesGcmDecryptSync } from "encloom/aes-gcm";
import { AES_GCM_NONCE_LENGTH, AES_GCM_TAG_LENGTH } from "encloom/constants";
import { randomBytes } from "encloom/random";
import { utf8ToBuffer, bufferToUtf8 } from "encloom/helpers/encoding";
const key = randomBytes(32); // AES-256 (16 or 24 also work)
const nonce = randomBytes(AES_GCM_NONCE_LENGTH); // 12 bytes
const pt = utf8ToBuffer("hello GCM");
const ct = aesGcmEncryptSync(nonce, key, pt);
console.log(AES_GCM_NONCE_LENGTH, AES_GCM_TAG_LENGTH); // 12 16
console.log(pt.length, ct.length); // 9 25 → 9 + 16-byte tag
console.log(bufferToUtf8(aesGcmDecryptSync(nonce, key, ct))); // hello GCM
Those 16 extra bytes are the tag. Flip a single bit in the ciphertext, the nonce, or the AAD, and the tag no longer matches: decryption fails instead of returning garbage.
AAD: sent in the clear, but tamper-proof
AAD is for data the receiver needs to read without decrypting, but that must not be altered: a format version, a user ID, a header. It isn't encrypted, but it goes into the tag computation. It must be identical on encrypt and decrypt.
import { aesGcmEncryptSync, aesGcmDecryptSync } from "encloom/aes-gcm";
import { randomBytes } from "encloom/random";
import { utf8ToBuffer, bufferToUtf8 } from "encloom/helpers/encoding";
const key = randomBytes(32);
const nonce = randomBytes(12);
const aad = utf8ToBuffer("user:42|v1"); // sent in the clear, but authenticated
const ct = aesGcmEncryptSync(nonce, key, utf8ToBuffer('{"balance":100}'), aad);
console.log(bufferToUtf8(aesGcmDecryptSync(nonce, key, ct, aad))); // {"balance":100}
try {
aesGcmDecryptSync(nonce, key, ct, utf8ToBuffer("user:43|v1"));
} catch (e) {
console.log("different AAD →", (e as Error).message);
}
const tampered = ct.slice();
tampered[0] ^= 0x01; // one bit of the ciphertext
try {
aesGcmDecryptSync(nonce, key, tampered, aad);
} catch (e) {
console.log("1 bit flipped →", (e as Error).message);
}
Note that the error is the same in both cases: aes-gcm: invalid tag. GCM doesn't tell you which part changed (and it shouldn't). When you see that message, check in this order: key, nonce, AAD, and whether the tag is where the library expects it.
A handy use: if you store encrypted records for many users, putting the user ID in the AAD stops someone from copying one record's encrypted blob into another and having it accepted as valid.
The nonce: 12 bytes, and never twice with the same key
encloom exports AES_GCM_NONCE_LENGTH (12) and accepts nonces of 8 bytes or more:
import { aesGcmEncryptSync } from "encloom/aes-gcm";
import { randomBytes } from "encloom/random";
const key = randomBytes(32);
const pt = new Uint8Array([1, 2, 3]);
for (const n of [12, 8, 16, 7]) {
try {
const ct = aesGcmEncryptSync(randomBytes(n), key, pt);
console.log(`nonce ${n} bytes → ok (${ct.length} bytes)`);
} catch (e) {
console.log(`nonce ${n} bytes →`, (e as Error).message);
}
}
nonce 12 bytes → ok (19 bytes)
nonce 8 bytes → ok (19 bytes)
nonce 16 bytes → ok (19 bytes)
nonce 7 bytes → AES-GCM: nonce must be at least 8 bytes
Why 12 if other sizes are accepted? Because 96 bits is the case GCM handles directly: the initial counter block is the nonce plus a 32-bit counter. With any other length, GCM first runs the nonce through GHASH to derive that counter. It works and it's in the spec, but it adds a step and not every implementation accepts it the same way. Testing on Node 24, createCipheriv accepted an 8-byte nonce and produced the same bytes as encloom, but crypto.subtle rejected it with Invalid initialization vector. With 12, 16, 64, and 128 bytes, all three implementations produced exactly the same output.
The operational rule: 12 random bytes per message (randomBytes(AES_GCM_NONCE_LENGTH)), unless you have to talk to a system that already uses another size. And with random nonces, NIST SP 800-38D (Section 8.3) caps the number of encryptions under a single key at 2³². If you get anywhere near that volume, rotate the key.
Why reusing a nonce breaks GCM
This is the bug that never produces an error message. Everything encrypts and decrypts fine. It just stops being secure.
GCM is counter-mode encryption: from the key and the nonce it generates a stream of bytes (the keystream) and XORs it with the plaintext. Same key + same nonce = same keystream. Encrypt two messages that way, and the XOR of the two ciphertexts equals the XOR of the two plaintexts: the key drops out of the equation.
import { aesGcmEncryptSync } from "encloom/aes-gcm";
import { randomBytes } from "encloom/random";
import { utf8ToBuffer, bufferToUtf8 } from "encloom/helpers/encoding";
const key = randomBytes(32);
const nonce = randomBytes(12); // ❌ the same nonce for two messages
const a = utf8ToBuffer('{"role":"user","id":7}');
const b = utf8ToBuffer('{"role":"admin","id":1');
const ctA = aesGcmEncryptSync(nonce, key, a);
const ctB = aesGcmEncryptSync(nonce, key, b);
// An attacker sees ctA and ctB, and knows (or guesses) message A.
// No key needed: ctA ⊕ ctB = a ⊕ b, because the keystream cancels out.
const recovered = new Uint8Array(b.length);
for (let i = 0; i < b.length; i++) recovered[i] = ctA[i] ^ ctB[i] ^ a[i];
console.log(bufferToUtf8(recovered)); // {"role":"admin","id":1
With JSON this is worse than it looks: the structure ({"role":", quotes, braces) is predictable, so guessing parts of a message is easy.
And there's a second break, less well known. The tag is computed with an internal authentication key H, derived from the AES key, plus a mask that depends on the nonce. With two messages under the same nonce, the mask cancels out and the attacker gets an equation whose unknown is H. Solving it yields H or a handful of candidates. With H, they can compute valid tags for modified ciphertexts. In other words, a single repeated nonce costs you integrity too, which is exactly what GCM added over CBC. This is the so-called forbidden attack (Joux, 2006).
To be clear: this isn't a bug in encloom, Node, or Web Crypto. It's a property of GCM's design, and every correct implementation behaves the same way. The failure is in the integration, in how nonces get generated. A library can only make the right thing easier: the encryptObjectAes128GcmJsonHex and encryptJsonAes256Gcm helpers generate a random nonce on every call, but with aesGcmEncryptSync you pass the nonce yourself, and making sure it never repeats is on you.
How it usually happens in practice:
- a fixed "for testing" nonce that makes it to production;
- a counter that resets when the process restarts, or that several instances share;
- a nonce derived from a timestamp in seconds;
- the same key and the same nonce in two services.
Node vs Web Crypto: the tag that gets left behind
Back to the bug from the intro. Both APIs compute exactly the same thing, but they hand it to you differently:
| Ciphertext | Tag | |
|---|---|---|
Node createCipheriv("aes-256-gcm")
|
update() + final()
|
separate, via getAuthTag()
|
Node createDecipheriv
|
update(ciphertext) |
separate, via setAuthTag(tag) before final()
|
Web Crypto subtle.encrypt/decrypt
|
ciphertext + tag in a single ArrayBuffer
|
included at the end |
encloom/aes-gcm |
ciphertext + tag in a Uint8Array
|
included at the end |
If the backend only sends update() + final(), the tag stays on the server. This script walks both directions:
import { createCipheriv, createDecipheriv, randomBytes as nodeRandom } from "node:crypto";
import { aesGcmDecryptSync, aesGcmEncryptSync } from "encloom/aes-gcm";
import { AES_GCM_TAG_LENGTH } from "encloom/constants";
import { concatBuffers, bufferToHex, bufferToUtf8, utf8ToBuffer } from "encloom/helpers/encoding";
const key = new Uint8Array(nodeRandom(32));
const iv = new Uint8Array(nodeRandom(12));
const msg = "order #42 confirmed";
// --- 1) Node backend: ciphertext and tag come out SEPARATELY
const cipher = createCipheriv("aes-256-gcm", key, iv);
const nodeCt = concatBuffers(cipher.update(msg, "utf8"), cipher.final());
const nodeTag = cipher.getAuthTag(); // 16 bytes, on its own
console.log("Node:", nodeCt.length, "bytes of ct +", nodeTag.length, "of tag");
// --- 2) The classic bug: send only the ciphertext to the browser
const subtleKey = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt", "decrypt"]);
try {
await crypto.subtle.decrypt({ name: "AES-GCM", iv }, subtleKey, Uint8Array.from(nodeCt));
} catch (e) {
console.log("Web Crypto without tag →", (e as Error).name);
}
// --- 3) The fix: ciphertext || tag
const wire = Uint8Array.from(concatBuffers(nodeCt, nodeTag)); // copy: see the TypeScript note below
const fromSubtle = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, subtleKey, wire);
console.log("Web Crypto with tag →", new TextDecoder().decode(fromSubtle));
console.log("encloom with tag →", bufferToUtf8(aesGcmDecryptSync(iv, key, wire)));
// --- 4) The other direction: Web Crypto / encloom → Node
const browserCt = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, subtleKey, Uint8Array.from(utf8ToBuffer(msg))),
);
const encloomCt = aesGcmEncryptSync(iv, key, utf8ToBuffer(msg));
console.log("Do Web Crypto and encloom produce the same bytes?",
bufferToHex(browserCt) === bufferToHex(encloomCt));
const body = browserCt.subarray(0, browserCt.length - AES_GCM_TAG_LENGTH);
const tag = browserCt.subarray(browserCt.length - AES_GCM_TAG_LENGTH);
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const back = decipher.update(body, undefined, "utf8") + decipher.final("utf8");
console.log("Node with setAuthTag →", back);
// --- 5) And if Node forgets setAuthTag
try {
const d = createDecipheriv("aes-256-gcm", key, iv);
d.update(browserCt); // the tag goes in as if it were ciphertext
d.final();
} catch (e) {
console.log("Node without setAuthTag →", (e as Error).message);
}
Output:
Node: 19 bytes of ct + 16 of tag
Web Crypto without tag → OperationError
Web Crypto with tag → order #42 confirmed
encloom with tag → order #42 confirmed
Do Web Crypto and encloom produce the same bytes? true
Node with setAuthTag → order #42 confirmed
Node without setAuthTag → Unsupported state or unable to authenticate data
A TypeScript note that ties back to the Buffer vs Uint8Array post: with a recent TypeScript (I tested with 7.0) and lib: ["dom"], crypto.subtle requires Uint8Array<ArrayBuffer>, while what Node and the helpers return is typed as Uint8Array<ArrayBufferLike>. At runtime it works the same, but tsc --strict complains. That's why the example uses Uint8Array.from(...), which copies the bytes into a plain ArrayBuffer before handing them to Web Crypto.
The rules that fall out of this:
-
Node → browser: concatenate
ciphertext || tag(or send the tag in a separate field and concatenate it beforesubtle.decrypt). -
Browser → Node: split off the last 16 bytes (
AES_GCM_TAG_LENGTH) and pass them tosetAuthTag. - The nonce isn't included: neither API puts it in the output. You have to send it yourself, usually in front of the ciphertext. It isn't secret, but it has to arrive.
- And as we saw in the Buffer vs Uint8Array post: at runtime,
Bufferis aUint8Array, so encloom accepts what Node returns without any conversion (so does Web Crypto, apart from the type complaint above). The problem is rarely the type; it's which bytes are inside.
The JSON helpers
For the "I have an object and want an encrypted string" case, encloom/aes-gcm ships two pairs of helpers with different formats. It's worth knowing what each one does before you pick.
Passphrase + hex: encryptObjectAes128GcmJsonHex
Encrypts JSON.stringify(data) with AES-128-GCM and a fresh random 12-byte nonce per call, and returns lowercase hex: nonce || ciphertext || tag. The nonce is included, so that one string is all you need to store or send.
import {
encryptObjectAes128GcmJsonHex,
decryptObjectAes128GcmJsonHex,
} from "encloom/aes-gcm";
const passphrase = "my-key-16-chars!"; // exactly 16 UTF-8 bytes (or fewer: padded with spaces)
const hex = encryptObjectAes128GcmJsonHex({ ok: true, n: 1 }, passphrase);
console.log(hex.length); // 2 × (12 nonce + 17 JSON + 16 tag) = 90
console.log(hex.slice(0, 24)); // the first 12 bytes: the nonce
const data = decryptObjectAes128GcmJsonHex(hex, passphrase) as { ok: boolean; n: number };
console.log(data); // { ok: true, n: 1 }
// Every call uses a fresh random nonce: same input, different output
console.log(hex === encryptObjectAes128GcmJsonHex({ ok: true, n: 1 }, passphrase)); // false
for (const p of ["a much longer passphrase", "señor-passphrase"]) {
try {
encryptObjectAes128GcmJsonHex({}, p);
} catch (e) {
console.log(JSON.stringify(p), "→", (e as Error).message);
}
}
Watch what it does with the key: this is not key derivation. The string is padded with spaces to 16 characters (padEnd) and UTF-8 encoded; the result must be exactly 16 bytes. A longer phrase fails, and so does a 16-character one containing ñ, because ñ takes 2 bytes in UTF-8. And a short phrase padded with spaces has little entropy.
It fits when the "passphrase" is really a random 16-character secret that you generated and keep in config. If what you have is a password a human typed, derive the key first with encloom/pbkdf2 and use the low-level API:
import { pbkdf2 } from "encloom/pbkdf2";
import { aesGcmEncryptSync, aesGcmDecryptSync } from "encloom/aes-gcm";
import { AES_GCM_NONCE_LENGTH } from "encloom/constants";
import { randomBytes } from "encloom/random";
import { utf8ToBuffer, bufferToUtf8 } from "encloom/helpers/encoding";
const password = utf8ToBuffer("a long passphrase with ñ and ü");
// No options: random 16-byte salt, 210,000 iterations, HMAC-SHA-256, 32-byte key
const { key, salt, iterations } = await pbkdf2(password);
const nonce = randomBytes(AES_GCM_NONCE_LENGTH);
const ct = aesGcmEncryptSync(nonce, key, utf8ToBuffer('{"ok":true}'));
// Store salt, iterations, nonce, and ct. To decrypt, derive again with the same salt:
const again = await pbkdf2(password, { salt, iterations });
console.log(iterations, bufferToUtf8(aesGcmDecryptSync(nonce, again.key, ct))); // 210000 {"ok":true}
Random key + Base64: encryptJsonAes256Gcm
Generates a random AES-256 key and a random nonce, and returns an AesGcmJsonWire: iv and stream (ciphertext + tag) in Base64, and key in unpadded Base64url.
import {
encryptJsonAes256Gcm,
decryptJsonAes256Gcm,
} from "encloom/aes-gcm";
import { AES_GCM_ENVELOPE_NONCE_MAX_LENGTH } from "encloom/constants";
import { base64ToBuffer, base64UrlToBuffer } from "encloom/helpers/encoding";
const wire = await encryptJsonAes256Gcm({ user: "ana", role: "admin" });
console.log(Object.keys(wire)); // [ 'iv', 'key', 'stream' ]
console.log(base64ToBuffer(wire.iv).length); // 12
console.log(base64UrlToBuffer(wire.key).length); // 32
// An existing system already sends 64-byte IVs: same shape, longer nonce
const legacy = await encryptJsonAes256Gcm({ user: "ana" }, 64);
console.log(base64ToBuffer(legacy.iv).length); // 64
console.log(await decryptJsonAes256Gcm(legacy)); // { user: 'ana' }
console.log(AES_GCM_ENVELOPE_NONCE_MAX_LENGTH); // 128
try {
await encryptJsonAes256Gcm({}, 7);
} catch (e) {
console.log((e as Error).message);
}
Same warning as in the Buffer vs Uint8Array post: the key travels inside the object. Anyone holding the full wire can decrypt it. It fits when you're going to protect wire.key with another layer (another key, a KMS, ECIES) or store it through a different channel than the iv and stream. It doesn't work as "I encrypted it and sent the result."
The second argument, nonceByteLength, exists for one specific case: talking to a system that already produces longer iv values (say, 64 bytes) without changing the shape of the object. It accepts integers from 8 to AES_GCM_ENVELOPE_NONCE_MAX_LENGTH (128). Decryption reads the length from wire.iv itself. If you don't have that requirement, don't pass it: the default is AES_GCM_NONCE_LENGTH.
Sync or async
Every function comes in both flavors (aesGcmEncrypt / aesGcmEncryptSync, encryptJsonAes256Gcm / encryptJsonAes256GcmSync, and so on). The async ones just wrap the sync version in a Promise: they don't use crypto.subtle under the hood. They produce the same bytes. Pick whichever fits your code.
What changed in 2.0.4
AES-GCM has used @noble/ciphers since the module was added in 2.0.0. What changed in 2.0.4 is AES-256-CBC (encloom/aes and the internal cipher in encloom/ecies): it dropped aes-js and moved to @noble/ciphers as well. Ciphertexts, PKCS#7 padding, and the public API are unchanged.
The practical reason: aes-js is CommonJS, and Angular flagged it when importing encloom/aes or encloom/ecies. Now both AES modes share a single ESM dependency, and the dependency tree of [email protected] comes down to three packages: @noble/ciphers, @noble/hashes, and @noble/secp256k1.
Checklist before you blame the key
| Symptom | Check |
|---|---|
OperationError in subtle.decrypt
|
Is the tag at the end of the ciphertext? |
Unsupported state or unable to authenticate data in Node |
Did you call setAuthTag with the last 16 bytes? |
aes-gcm: invalid tag |
Key, nonce, AAD, and tag position: all four must match |
Invalid initialization vector in Web Crypto |
Is the nonce 12 bytes? |
| Everything works, but the nonce is fixed or a shared counter | That's not a visible bug: it's the bug |
When this fits
encloom/aes-gcm makes sense if the same code runs on Node, in the browser, or in a Worker; if you want output byte-for-byte identical to Web Crypto without dealing with CryptoKey or async APIs; or if you need a synchronous version. If you only encrypt in the browser and already work with non-extractable CryptoKeys, Web Crypto on its own gives you the same thing.
Either way, no library protects you from reusing a nonce. That depends on how you generate and store your nonces.
References
-
encloom on npm · repo ·
encloom/aes-gcm - Previous: Buffer vs Uint8Array in TypeScript crypto · HMAC over JSON in TypeScript
- NIST SP 800-38D (GCM), Section 8.3 · A. Joux, "Authentication Failures in NIST version of GCM" (2006)
- Web Crypto
AES-GCM· Nodecrypto.createCipheriv/getAuthTag/setAuthTag
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.