You call crypto.subtle.decrypt() on an AES-GCM ciphertext and get back:
DOMException: The operation failed for an operation-specific reason
name: "OperationError"No stack trace pointing at a line. No mention of which byte was wrong. Just “the operation failed.” If you have spent twenty minutes assuming your code has a bug somewhere in the decode step, it is worth knowing upfront: that vagueness is not a missing detail the browser forgot to include, it is the entire point of the error.
AES-GCM is an authenticated cipher. Every ciphertext it produces carries a 16-byte authentication tag, and decryption first checks that tag against the ciphertext, key, IV, and any associated data before it releases a single byte of plaintext. If the tag does not match, the Web Crypto specification requires the implementation to fail without describing why. That is deliberate: authenticated encryption exists to close the exact kind of side channel a more descriptive error would reopen. If the browser told you whether the key was wrong versus whether the ciphertext was tampered with, an attacker submitting crafted ciphertexts could use that distinction to slowly extract information, the same class of attack that padding-oracle exploits used against unauthenticated CBC mode years ago. So OperationError is correct behavior, not a bug, and no amount of digging through the ciphertext bytes by hand will recover a more specific reason. What you can do instead is work through the actual causes in order of likelihood.
This accounts for the large majority of these errors in practice. If the key was derived from a password using different parameters on encrypt versus decrypt (a different salt, a differentPBKDF2 iteration count, or a different derived key length), the resulting key bytes are different even though the password matched. If the key was imported with the wrong keyUsages array, or generated fresh instead of reused from storage, the same problem shows up. Before checking anything else, confirm the key that decrypts is byte-for-byte the same one that encrypted, not just derived from the same input.
The initialization vector is not secret, but it has to be exact. AES-GCM needs the same IV on decrypt that was used on encrypt, and it is common to encrypt and store the ciphertext without also storing the IV alongside it, since it is easy to assume the key alone is enough. It is not. The standard pattern is to prepend or append the 12-byte IV to the stored ciphertext and split it back out before decrypting:
// encrypt: store iv + ciphertext together
const iv = crypto.getRandomValues(new Uint8Array(12))
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext)
const combined = new Uint8Array(iv.length + ciphertext.byteLength)
combined.set(iv, 0)
combined.set(new Uint8Array(ciphertext), iv.length)
// decrypt: split them back apart first
const iv = combined.slice(0, 12)
const ciphertext = combined.slice(12)
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)A 12-byte IV is the recommended and most broadly compatible length. Web Crypto accepts other lengths, but if the encrypting side and decrypting side disagree on IV length, or one side regenerates a random IV at decrypt time instead of reusing the original, decryption fails every time with the same bare error.
Web Crypto appends the 16-byte authentication tag to the end of the ciphertext automatically on encrypt, and expects it still attached on decrypt. If the ciphertext passes through anything that could drop trailing bytes, a base64 or hex round-trip with a bug, a database column with a length limit, a copy-paste that lost the last few characters, the tag no longer matches and decryption fails. This is the one to suspect specifically when the ciphertext was stored or transmitted as text rather than kept as raw bytes the whole way through, since text encodings are where truncation bugs usually creep in.
If additionalData was passed to encrypt(), the exact same bytes have to be passed to decrypt(). Associated data is authenticated but not encrypted, so it is easy to forget it needs to travel alongside the ciphertext just like the IV does. A common version of this bug: encrypting with a user ID as associated data, then decrypting after the user ID field changed shape somewhere in the pipeline (a number instead of a string, or a different serialization), producing different bytes even though the logical value looks the same.
The tagLength option defaults to 128 bits and almost nothing has a reason to change it. If one side of the code explicitly set a shorter tagLength and the other side left it at the default, the two sides disagree on where the ciphertext ends and the tag begins, which fails the same way as every other mismatch here. Unless there is a specific interoperability reason to shorten it, leaving tagLength unset on both sides removes this as a possible cause entirely.
Since the browser will not tell you which of these it is, the fastest way to narrow it down is to reproduce encrypt and decrypt back to back in the same place, with the same key, IV, and associated data held in variables rather than passed through storage or a network hop. If that round-trip succeeds, the bug is in how one of those values is being serialized, stored, or reconstructed between the two calls, not in the cryptography itself. The Encrypt / Decrypt tool on this site runs AES-256-GCM the same way, entirely in your browser, which makes it a quick way to confirm a given key and ciphertext pair actually decrypt correctly in isolation before assuming the problem is in the algorithm rather than in the plumbing around it.