Encrypt and Decrypt Data With AWS KMS and SDK v3

A small metal padlock resting on the keys of a dark laptop keyboard

Photo by Nathan Thomas on Pexels

To KMS encrypt and decrypt with AWS SDK v3, send EncryptCommand from @aws-sdk/client-kms with a KeyId (key ID, ARN or alias/name) and a Plaintext Uint8Array of up to 4,096 bytes. Store the returned CiphertextBlob, often as base64. To read it back, send DecryptCommand with the same blob and the same EncryptionContext. For larger data, use GenerateDataKey and encrypt locally.

This guide is for Node.js and TypeScript developers who need to protect a value in their own code: a webhook secret in a database column, a customer file before it goes to S3, a token passed between services. Where a managed service already encrypts for you (S3, RDS, SSM SecureString), let it; KMS calls in your own code are for data those services don’t cover.

You’ll get a working kms encrypt decrypt example in AWS SDK v3, envelope encryption with a data key and AES-256-GCM from node:crypto, what each request costs, the IAM policy, and a table of the errors you’ll meet. All samples were type-checked with strict tsc against @aws-sdk/client-kms 3.1141.0 and run in September 2026 against a mocked KMS client, with real AES-GCM encryption for the envelope round trip.

Encrypt or GenerateDataKey: which should you use?

Situation Use Why
A secret up to 4 KB (password, API key, token) Encrypt / Decrypt One call each; the key never leaves KMS.
Files, records or payloads larger than 4 KB GenerateDataKey + local AES-GCM KMS only wraps a 32-byte data key; your data is encrypted in your process.
Many small records at high volume GenerateDataKey, one key per batch Far fewer KMS requests, so lower cost and less throttling.
You want a message format, key caching and multiple keys handled for you AWS Encryption SDK (@aws-crypto/client-node) It implements envelope encryption as a library; see the limits section.

The 4,096-byte limit comes from the KMS Encrypt API reference for symmetric keys. RSA keys allow much less: 190 to 446 bytes with RSAES_OAEP_SHA_256, depending on key size.

Prerequisites

  • Node.js 18 or later, a project with "type": "module" (the samples use top-level await), @aws-sdk/client-kms, and tsx to run TypeScript.
  • A symmetric encryption KMS key (KeyUsage ENCRYPT_DECRYPT) with an alias such as alias/app-secrets. A customer managed key costs $1 a month and more once it rotates; the script to find KMS keys without rotation explains how rotation changes that.
  • Credentials the SDK can find; the guide to AWS SDK v3 credential providers covers profiles, SSO and roles.

How to KMS encrypt and decrypt with AWS SDK v3, step by step

  1. Create a KMSClientIn the Region of the key. A key in another account needs its key ARN or alias ARN, not a bare alias.
  2. Turn the value into bytesPlaintext is a Uint8Array. Use TextEncoder for strings; the SDK handles base64 on the wire.
  3. Pick an encryption contextNon-secret key-value pairs, such as the app and the purpose, that must match on decrypt.
  4. Send EncryptCommandKeep CiphertextBlob. It includes metadata identifying the key, so symmetric decrypts don’t strictly need KeyId.
  5. Store it as base64Buffer.from(blob).toString("base64") for JSON, environment variables or a text column.
  6. Send DecryptCommandPass the blob, the same context and, as AWS recommends, the KeyId you expect.

Example: encrypt and decrypt a small secret

kms-encrypt-decrypt.ts

// kms-encrypt-decrypt.ts: encrypt a small secret with a KMS key and decrypt it again.
// Usage: KMS_KEY_ID=alias/app-secrets npx tsx kms-encrypt-decrypt.ts
import { KMSClient, EncryptCommand, DecryptCommand } from "@aws-sdk/client-kms";

const region = process.env.AWS_REGION ?? "us-east-1";
const keyId = process.env.KMS_KEY_ID ?? "alias/app-secrets"; // key ID, key ARN, alias name or alias ARN
const kms = new KMSClient({ region });

// Non-secret key-value pairs bound to the ciphertext. Decrypt must pass exactly the same pairs.
const encryptionContext = { app: "billing", purpose: "stripe-webhook-secret" };

const plaintext = new TextEncoder().encode("whsec_0123456789abcdef");
if (plaintext.byteLength > 4096) throw new Error("Encrypt takes at most 4,096 bytes; use envelope encryption");

const encrypted = await kms.send(new EncryptCommand({
  KeyId: keyId,
  Plaintext: plaintext,
  EncryptionContext: encryptionContext,
}));
if (!encrypted.CiphertextBlob) throw new Error("Encrypt returned no ciphertext");

// CiphertextBlob is a Uint8Array. Base64 it for JSON, environment variables or a database column.
const stored = Buffer.from(encrypted.CiphertextBlob).toString("base64");
console.log(`Encrypted with ${encrypted.KeyId} (${encrypted.EncryptionAlgorithm})`);

const decrypted = await kms.send(new DecryptCommand({
  CiphertextBlob: Buffer.from(stored, "base64"),
  KeyId: keyId, // optional for symmetric keys, but pins the key you expect
  EncryptionContext: encryptionContext,
}));
console.log(`Decrypted: ${new TextDecoder().decode(decrypted.Plaintext)}`);
Output

Encrypted with arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab (SYMMETRIC_DEFAULT)
Decrypted: whsec_0123456789abcdef

The output comes from a run against a mocked KMS client; the key ARN is an example. Encrypt returns the key ARN even when you pass an alias, which is worth logging: aliases can be repointed, and the ARN tells you which key actually holds your data.

The encryption context is the part most code skips. It’s authenticated, not secret: KMS binds it to the ciphertext, and a decrypt with different pairs fails with InvalidCiphertextException. That stops a ciphertext copied from one record or tenant being decrypted as another. The API reference warns that the context can appear in plaintext in CloudTrail logs, so never put secrets in it. It also lets you write IAM conditions on kms:EncryptionContext:app, shown under permissions below.

Tip: for configuration secrets, you may not need KMS calls at all. Reading a Secrets Manager secret with SDK v3 or getting an SSM SecureString parameter uses KMS behind the scenes and adds rotation and access history.

Example: envelope encryption with GenerateDataKey and AES-256-GCM

For anything over 4 KB, ask KMS for a data key and encrypt locally. GenerateDataKey returns the key twice: Plaintext to use now and CiphertextBlob, the same key encrypted under your KMS key, to store next to the data. The API reference’s pattern is: encrypt, erase the plaintext key from memory, store the encrypted key with the data.

envelope.ts

// envelope.ts: envelope encryption with a KMS data key and AES-256-GCM from node:crypto.
// KMS encrypts only the 32-byte data key; your data is encrypted locally, so size is not limited to 4 KB.
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { KMSClient, GenerateDataKeyCommand, DecryptCommand } from "@aws-sdk/client-kms";

export interface Envelope {
  v: 1;
  keyId: string; // KMS key ARN that wrapped the data key
  encryptedKey: string; // base64 CiphertextBlob from GenerateDataKey
  iv: string; // base64, 12 random bytes per message
  tag: string; // base64, 16-byte GCM authentication tag
  ciphertext: string; // base64
}

export type Context = Record<string, string>;

// Bind the encryption context to the data too, as AES-GCM additional authenticated data.
const aad = (context: Context): Buffer =>
  Buffer.from(JSON.stringify(Object.keys(context).sort().map((k) => [k, context[k]])));

export async function encryptEnvelope(kms: KMSClient, keyId: string, data: Uint8Array, context: Context): Promise<Envelope> {
  const { Plaintext, CiphertextBlob, KeyId } = await kms.send(new GenerateDataKeyCommand({
    KeyId: keyId,
    KeySpec: "AES_256",
    EncryptionContext: context,
  }));
  if (!Plaintext || !CiphertextBlob) throw new Error("GenerateDataKey returned no key");
  try {
    const iv = randomBytes(12);
    const cipher = createCipheriv("aes-256-gcm", Plaintext, iv);
    cipher.setAAD(aad(context));
    const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
    return {
      v: 1,
      keyId: KeyId ?? keyId,
      encryptedKey: Buffer.from(CiphertextBlob).toString("base64"),
      iv: iv.toString("base64"),
      tag: cipher.getAuthTag().toString("base64"),
      ciphertext: ciphertext.toString("base64"),
    };
  } finally {
    Plaintext.fill(0); // erase the plaintext data key as soon as it's used
  }
}

export async function decryptEnvelope(kms: KMSClient, envelope: Envelope, context: Context): Promise<Buffer> {
  const { Plaintext } = await kms.send(new DecryptCommand({
    CiphertextBlob: Buffer.from(envelope.encryptedKey, "base64"),
    KeyId: envelope.keyId,
    EncryptionContext: context,
  }));
  if (!Plaintext) throw new Error("Decrypt returned no key");
  try {
    const decipher = createDecipheriv("aes-256-gcm", Plaintext, Buffer.from(envelope.iv, "base64"));
    decipher.setAAD(aad(context));
    decipher.setAuthTag(Buffer.from(envelope.tag, "base64"));
    return Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64")), decipher.final()]);
  } finally {
    Plaintext.fill(0);
  }
}
use-envelope.ts

// use-envelope.ts: encrypt a file of any size, store the envelope as JSON, then decrypt it.
// Usage: KMS_KEY_ID=alias/app-data npx tsx use-envelope.ts customers.csv
import { readFileSync, writeFileSync } from "node:fs";
import { KMSClient } from "@aws-sdk/client-kms";
import { encryptEnvelope, decryptEnvelope, type Envelope } from "./envelope.js";

const kms = new KMSClient({ region: process.env.AWS_REGION ?? "us-east-1" });
const keyId = process.env.KMS_KEY_ID ?? "alias/app-data";
const file = process.argv[2] ?? "customers.csv";
const context = { app: "crm", file };

const data = readFileSync(file);
const envelope = await encryptEnvelope(kms, keyId, data, context);
writeFileSync(`${file}.enc.json`, JSON.stringify(envelope));
console.log(`Encrypted ${data.length} bytes into ${file}.enc.json with key ${envelope.keyId}`);

const saved = JSON.parse(readFileSync(`${file}.enc.json`, "utf8")) as Envelope;
const restored = await decryptEnvelope(kms, saved, context);
console.log(`Round trip ok: ${restored.equals(data)}`);

try {
  await decryptEnvelope(kms, saved, { ...context, file: "other.csv" });
} catch (err) {
  console.log(`Wrong context rejected: ${err instanceof Error ? err.name : String(err)}`);
}
Output

Encrypted 1048576 bytes into customers.csv.enc.json with key arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab
Round trip ok: true
Wrong context rejected: InvalidCiphertextException

The 1 MB round trip used real AES-256-GCM and a mocked KMS client that enforces the encryption context the way KMS does. A few details matter:

  • A new 12-byte IV for every message. Reusing an IV with the same GCM key breaks its security. randomBytes(12) per call avoids it.
  • Keep the auth tag. decipher.final() throws if the tag, IV, AAD or ciphertext changed; in our test, flipping one ciphertext bit failed the decrypt. The Node.js crypto.createCipheriv documentation covers GCM options and setAuthTag.
  • The context protects both layers. KMS checks it when unwrapping the data key, and the same pairs go into AES-GCM as additional authenticated data.
  • fill(0) is best effort. It clears the SDK’s buffer, but JavaScript gives no guarantee that no other copy exists in memory.

Store the envelope as JSON or split it into columns. When you need to switch KMS keys, ReEncrypt can re-wrap the small encryptedKey without touching the data.

What do KMS encrypt and decrypt requests cost?

As of September 2026, the AWS Price List for AWS KMS (published 11 September 2026) shows these rates in US East (N. Virginia):

Item Price
Symmetric requests (Encrypt, Decrypt, GenerateDataKey and others) $0.03 per 10,000
Free tier 20,000 requests a month, across all Regions
RSA 2048 asymmetric requests $0.03 per 10,000
Other asymmetric requests $0.15 per 10,000
Customer managed key $1 a month per key version (rotations add versions)

Worked example: encrypting 5 million records a month with one Encrypt call each costs (5,000,000 − 20,000) ÷ 10,000 × $0.03 = $14.94 a month, and decrypting them all again costs another $15.00. With envelope encryption and one data key per batch of 1,000 records, the same month needs 5,000 GenerateDataKey calls, inside the free tier.

Throughput matters as much as price. The symmetric cryptographic operations share one request quota per account and Region: 10,000 per second by default, 20,000 in Regions such as US East (Ohio) and Europe (Frankfurt), and 100,000 in US East (N. Virginia), US West (Oregon) and Europe (Ireland). Calls AWS services make on your behalf, such as S3 SSE-KMS uploads, count too. Above the quota KMS returns ThrottlingException, which the SDK retries; configuring retries and timeouts in AWS SDK v3 shows how to tune that.

Which IAM permissions do KMS encrypt and decrypt need?

The caller needs kms:Encrypt, kms:Decrypt or kms:GenerateDataKey on the key. AWS recommends granting Decrypt in the key policy where possible; an IAM policy works when the key policy allows IAM policies to grant access, as the default key policy does. Scope it to the key ARN, never "Resource": "*", and pin the context:

app-kms-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "UseAppKeyForCrmOnly",
      "Effect": "Allow",
      "Action": [
        "kms:Encrypt",
        "kms:Decrypt",
        "kms:GenerateDataKey"
      ],
      "Resource": "arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab",
      "Condition": {
        "StringEquals": {
          "kms:EncryptionContext:app": "crm"
        }
      }
    }
  ]
}

A service that only reads data needs just kms:Decrypt. The IAM policy generator for TypeScript AWS SDK code lists the actions your code calls, and the guide to reviewing an IAM policy for least privilege helps tighten the result.

Troubleshooting KMS errors and knowing the limits

These exception names come from the KMS API reference; in SDK v3 they’re exported classes you can test with instanceof or by err.name:

Error Usual cause
InvalidCiphertextException Encryption context differs (it’s case-sensitive and must match exactly), or the blob was corrupted, often by double base64 encoding.
IncorrectKeyException The KeyId in Decrypt isn’t the key that encrypted the data.
AccessDeniedException Neither the key policy nor an IAM policy allows the action; see troubleshooting AWS IAM access denied errors.
DisabledException / KMSInvalidStateException The key is disabled or pending deletion. The script to find unused KMS keys shows how key state and last use are checked.
InvalidKeyUsageException The key isn’t ENCRYPT_DECRYPT, or the algorithm doesn’t fit the key spec.
NotFoundException Wrong Region, a typo in the alias, or a bare alias for a key in another account.
ThrottlingException Over the shared request quota; batch with data keys.

Limits worth knowing before you build on this:

  • Hand-rolled envelopes are your format to maintain. The AWS Encryption SDK gives you a standard message format, data key caching and multiple wrapping keys. Its output isn’t compatible with Decrypt alone, and yours isn’t compatible with it, so pick one early.
  • Encrypted data is only as recoverable as the key. A key scheduled for deletion makes every ciphertext under it unreadable once deleted.
  • Moving from SDK v2 means kms.encrypt(params).promise() becomes kms.send(new EncryptCommand(params)), and blobs arrive as Uint8Array, not Buffer. The free AWS SDK v2 to v3 converter and the guide to migrating a Node.js app from SDK v2 to v3 cover the rest.
  • Test without AWS. Mock KMSClient as shown in mocking AWS SDK v3 in unit tests, and make the mock reject a wrong context so tests catch mismatches.

Plaintext secrets often hide in places KMS should cover: the scripts to find plaintext SSM parameters and find secrets in Lambda environment variables show where to start.

Frequently asked questions

What is the maximum size KMS Encrypt can handle?

4,096 bytes for symmetric keys. RSA keys accept 190 to 470 bytes depending on key size and padding. For anything larger, use GenerateDataKey and encrypt locally.

Do I need to pass KeyId to Decrypt?

Not for symmetric keys, because the ciphertext blob identifies the key. AWS still recommends passing it, so a ciphertext encrypted under a different key fails instead of silently decrypting.

Why does Decrypt fail with InvalidCiphertextException?

Most often the encryption context doesn’t match exactly, including case, or the ciphertext was altered, for example base64-encoded twice before storage.

Is the CiphertextBlob base64 in SDK v3?

No. In the SDK it’s a raw Uint8Array; only the HTTP API and the AWS CLI show it as base64. Encode it yourself if you store it as text.

Can AWS KMS decrypt data encrypted by the AWS Encryption SDK?

No. The Encryption SDK uses its own message format, so decrypt it with the Encryption SDK, not the KMS Decrypt API.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud