Skip to main content
Security7 min readMarch 3, 2026

Data Encryption in Applications: At Rest, In Transit, and In Memory

A developer's guide to data encryption — encrypting database fields, TLS in transit, key management patterns, and handling sensitive data in memory without leakage.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

Data Encryption in Applications: At Rest, In Transit, and In Memory

Encryption is frequently misunderstood in application development. Teams think "we use HTTPS" and consider data protection addressed. HTTPS encrypts data in transit — the network path between client and server. It says nothing about how data is stored, how it moves internally between services, or how long sensitive values linger in application memory.

Comprehensive data protection requires thinking about all three states: in transit, at rest, and in memory. Here is what each requires and how to implement it.

Data in Transit

HTTPS (TLS) encrypts data between the user's browser and your server. This is table stakes in 2026. Beyond HTTPS for public-facing connections, consider:

Service-to-service communication. If your application has multiple services communicating internally, that traffic also needs encryption. Internal network traffic that is unencrypted is readable to anyone with access to that network — a breach that gets an attacker onto your internal network can now read all your internal API calls.

For services communicating over a private network between services you trust, mTLS (mutual TLS) provides both encryption and authentication. Both parties present certificates, and neither accepts connections from an unauthenticated peer. This is more complex to manage but provides strong guarantees.

For simpler internal service communication, HTTPS with a self-signed certificate or a private CA provides encryption without mutual authentication. At minimum, enforce HTTPS for all inter-service communication.

Database connections. Many applications connect to their database over an unencrypted connection, assuming the database is on the same network and therefore safe. Enable TLS for database connections:

// Prisma with SSL required
const prisma = new PrismaClient({
  datasources: {
    db: {
      url: process.env.DATABASE_URL + "?sslmode=require",
    },
  },
});

For PostgreSQL, ensure your database server is configured with SSL enabled and your connection string includes sslmode=require or sslmode=verify-full (which also verifies the certificate chain).

Data at Rest

HTTPS protects your data as it crosses the network. It does not protect your data when it is stored. A database dump, a stolen backup, a compromised read replica — these expose your data regardless of how well you secured transit.

Full-disk encryption. Modern cloud providers encrypt storage volumes at rest by default. Verify this is enabled for every volume in your infrastructure. AWS EBS volumes should have encryption at rest enabled. This protects against the physical disk being removed from a data center, but not against attackers who gain access to your running system.

Application-level encryption. For sensitive fields (healthcare data, PII, financial information, API keys, OAuth tokens, social security numbers), encrypt the data in your application before it reaches the database. The database stores ciphertext. Even a full database dump is useless without the encryption key.

import { createCipheriv, createDecipheriv, randomBytes } from "crypto";

const ALGORITHM = "aes-256-gcm";
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, "hex"); // 32 bytes

function encrypt(plaintext: string): string {
  const iv = randomBytes(12); // 96-bit IV for GCM
  const cipher = createCipheriv(ALGORITHM, KEY, iv);

  let ciphertext = cipher.update(plaintext, "utf8", "hex");
  ciphertext += cipher.final("hex");
  const tag = cipher.getAuthTag().toString("hex");

  // Store iv:tag:ciphertext together
  return `${iv.toString("hex")}:${tag}:${ciphertext}`;
}

function decrypt(encrypted: string): string {
  const [ivHex, tagHex, ciphertext] = encrypted.split(":");
  const iv = Buffer.from(ivHex, "hex");
  const tag = Buffer.from(tagHex, "hex");

  const decipher = createDecipheriv(ALGORITHM, KEY, iv);
  decipher.setAuthTag(tag);

  let plaintext = decipher.update(ciphertext, "hex", "utf8");
  plaintext += decipher.final("utf8");
  return plaintext;
}

AES-256-GCM is the right algorithm for this purpose. It provides both confidentiality (AES) and authentication (GCM's authentication tag). The authentication tag prevents tampering — if the ciphertext is modified, decryption fails with an error. Always use GCM mode, not CBC or ECB, which lack authentication.

The initialization vector (IV) must be unique for every encryption operation. Reusing an IV with the same key completely breaks GCM's security. Generate a new random IV for every encryption call and store it alongside the ciphertext.

Searchable encryption. A limitation of application-level encryption is that you cannot query encrypted fields directly. You cannot do WHERE encrypted_email = $1 because the stored value is ciphertext, not plaintext. Workarounds include storing a hash of the value alongside the encrypted value (for exact-match lookups), decrypting in the application and filtering in memory (expensive at scale), or using a specialized searchable encryption scheme (complex, limited support).

For fields you need to query, hash alongside encryption:

import { createHash } from "crypto";

function hashForLookup(value: string): string {
  // HMAC-SHA256 with a separate lookup key (not the encryption key)
  const LOOKUP_KEY = process.env.LOOKUP_HMAC_KEY!;
  return createHmac("sha256", LOOKUP_KEY).update(value).digest("hex");
}

// Store both
await db.user.create({
  data: {
    emailEncrypted: encrypt(email),
    emailHash: hashForLookup(email.toLowerCase()),
  },
});

// Query by hash
const user = await db.user.findFirst({
  where: { emailHash: hashForLookup(email.toLowerCase()) },
});
if (user) {
  const decryptedEmail = decrypt(user.emailEncrypted);
}

Key Management

Encryption is only as secure as your key management. A well-implemented encryption scheme with a compromised key offers no protection.

Never hardcode encryption keys. Keys must come from environment variables, secrets management systems, or dedicated key management services. A key in source code is a key shared with everyone who has repository access and everyone who will ever have access to the repository history.

Use a KMS for production. AWS KMS, Google Cloud KMS, and HashiCorp Vault provide managed key management with audit logging, key rotation, and access controls. Rather than loading your encryption key as an environment variable (which puts the raw key material in your process environment), use a KMS:

import { KMSClient, EncryptCommand, DecryptCommand } from "@aws-sdk/client-kms";

const kms = new KMSClient({ region: "us-east-1" });

async function encryptWithKms(plaintext: string): Promise<string> {
  const command = new EncryptCommand({
    KeyId: process.env.KMS_KEY_ID!,
    Plaintext: Buffer.from(plaintext),
  });
  const response = await kms.send(command);
  return Buffer.from(response.CiphertextBlob!).toString("base64");
}

async function decryptWithKms(ciphertext: string): Promise<string> {
  const command = new DecryptCommand({
    CiphertextBlob: Buffer.from(ciphertext, "base64"),
  });
  const response = await kms.send(command);
  return Buffer.from(response.Plaintext!).toString("utf8");
}

This pattern means your application never holds the raw key material. The KMS holds the key. Every encryption and decryption is an API call that KMS logs. Access to the key is controlled by IAM policies.

Envelope encryption for bulk data. For encrypting large amounts of data, do not call the KMS for every record — API latency and costs add up. Use envelope encryption: generate a data encryption key (DEK) locally, encrypt your data with the DEK, then encrypt the DEK with a KMS key master key. Store the encrypted DEK alongside the encrypted data. Decrypt the DEK with KMS when you need to decrypt data.

Data in Memory

Sensitive data in memory — passwords during authentication, decrypted PII, API keys — lingers longer than developers expect. Garbage collectors do not immediately reclaim memory. Memory dumps, core dumps, and swap files can expose this data.

In most high-level languages, you have limited control over when memory is reclaimed. Practical mitigations:

Do not store sensitive data in logs. A log statement that logs the full request body will log passwords, tokens, and personal data to disk. Redact sensitive fields explicitly.

Minimize the scope of sensitive variables. Decrypt data close to where you use it, use it, then let the variable go out of scope. Do not decrypt at the top of a function and use the decrypted value ten function calls later.

Use secure comparison functions for sensitive comparisons:

import { timingSafeEqual } from "crypto";

function constantTimeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) {
    // Return false but still do a comparison to prevent timing side channels
    timingSafeEqual(Buffer.from(a), Buffer.from(a));
    return false;
  }
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

The timing-safe comparison prevents timing attacks where an attacker measures response time differences to determine how many characters of a token they guessed correctly.

For applications handling very sensitive data (healthcare, financial), consider memory-secure libraries that zero memory buffers after use and prevent sensitive data from being paged to swap. These are rare requirements for most web applications but standard for high-security environments.


If you want help designing a data encryption strategy for sensitive fields in your application or need a review of your current cryptographic implementation, book a session at https://calendly.com/jamesrossjr.


Keep Reading