Encryption Integration Guide
What is encryption, really?
Pick the primitive, let an audited library do the math — then protect PII/PHI at the field level.
AES-256-GCM with a fresh 12-byte IV per message for at-rest data; sealed boxes (libsodium, X25519) to encrypt to a public key; Argon2id for passwords. At scale, envelope encryption: a DEK encrypts data, a KMS-held KEK wraps the DEK, stored separately. For codeAmani, DoseVault PHI (HIPAA) and all PII (KDPA 2019) get field-level AES-256-GCM so the DB only ever holds ciphertext — plus masking and tokenization for M-Pesa ids and logs. OWASP on rolling your own crypto: don't.
Six primitives you reach for
Right category first — reversible vs one-way, who holds the key — then the implementation is mechanical.
███████╗███╗ ██╗ ██████╗██████╗ ██╗ ██╗██████╗ ████████╗██╗ ██████╗ ███╗ ██╗
██╔════╝████╗ ██║██╔════╝██╔══██╗╚██╗ ██╔╝██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║
█████╗ ██╔██╗ ██║██║ ██████╔╝ ╚████╔╝ ██████╔╝ ██║ ██║██║ ██║██╔██╗ ██║
██╔══╝ ██║╚██╗██║██║ ██╔══██╗ ╚██╔╝ ██╔═══╝ ██║ ██║██║ ██║██║╚██╗██║
███████╗██║ ╚████║╚██████╗██║ ██║ ██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║
╚══════╝╚═╝ ╚═══╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝Encryption Integration Guide
Focus: pick the right primitive for the job and let an audited library do the math. Symmetric (AES-256-GCM) for data you can decrypt, hashing (Argon2id) for passwords you never can, sealed boxes for "encrypt to a public key", and envelope encryption for keys at scale. For codeAmani the load-bearing case is masking PII and protecting HIPAA/PHI in DoseVault — field-level encryption and tokenization on top of these primitives.
Overview
Encryption is not one thing. The single most common mistake is reaching for the wrong tool: hashing a credit-card number you need to charge later, "encrypting" a password you should never be able to read back, or hand-rolling AES with a static IV. Get the category right first, then the implementation is almost mechanical.
Four categories cover almost everything you'll touch:
- Symmetric encryption — one secret key encrypts and decrypts. Fast, used for bulk data. Default: AES-256-GCM (or XChaCha20-Poly1305). Reversible — you can get the plaintext back.
- Asymmetric encryption — a public key encrypts, a separate private key decrypts. Solves the "how do I share a key with someone I've never met" problem. RSA (legacy) or ECC/Curve25519 (preferred, smaller, faster).
- Hashing — a one-way function. Not encryption — there is no key and no way back. Used for password storage (Argon2id) and integrity (SHA-256). If you can "decrypt" a hash, it wasn't a hash.
- E2EE — end-to-end: only the two endpoints hold keys; the server relays ciphertext it cannot read. Built from the above (sealed boxes for one-shot, double-ratchet for live messaging).
Layered on top of where the data lives: in transit (TLS — handled by the platform, just enforce HTTPS) vs at rest (your job — field-level encryption, envelope/KMS).
Golden rule, straight from OWASP: don't roll your own crypto. Use AES-GCM via the Web Crypto API (built into browsers and Node 20+), libsodium (crypto_box_seal, crypto_secretbox), @noble/*, or Google Tink. These are audited, constant-time, and hard to misuse. Hand-written ECB-mode loops and static IVs are how data leaks.
Official Documentation
| Source | URL | What it covers |
|---|---|---|
| MDN — SubtleCrypto.encrypt | https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt | Web Crypto AES-GCM encrypt/decrypt, IV rules |
| MDN — SubtleCrypto.generateKey | https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey | Key generation for AES, RSA, ECDH |
| libsodium — Sealed boxes | https://doc.libsodium.org/public-key_cryptography/sealed_boxes | Anonymous public-key encryption (X25519 + XSalsa20-Poly1305) |
| libsodium.js | https://github.com/jedisct1/libsodium.js | JS wrapper API, sodium.ready, function names |
| OWASP Cryptographic Storage | https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html | Algorithm/key-length choices, envelope encryption, KMS |
| OWASP Password Storage | https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html | Argon2id / scrypt / bcrypt parameters |
The taxonomy at a glance
| Category | Reversible? | Key model | Reach for | codeAmani example |
|---|---|---|---|---|
| Symmetric | Yes | one shared key | AES-256-GCM | Encrypt a PHI column at rest |
| Asymmetric | Yes | public + private | ECC/Curve25519, RSA-OAEP | Encrypt to a recipient's public key |
| Hashing | No | none | Argon2id (passwords), SHA-256 (integrity) | Store login passwords |
| E2EE | Yes (endpoints only) | per-party keys | sealed box / double ratchet | Patient-to-clinician message |
Encryption vs hashing — the one distinction to never blur: encryption is reversible with the key; hashing is a one-way trapdoor with no key. Passwords get hashed (you only ever compare hashes), never encrypted. M-Pesa amounts you need to display later get encrypted. If you ever find yourself wanting to "decrypt the password to email it to the user," stop — that's a design bug.
Symmetric: AES-256-GCM with the Web Crypto API
AES-256-GCM is the OWASP-recommended default for data at rest. GCM is an authenticated mode — it detects tampering, so you get confidentiality and integrity in one call. Web Crypto is built into browsers and Node 20+, so there's no dependency.
Two non-negotiable rules:
- The IV must be 12 bytes and unique per encryption under a given key. Reusing an IV with the same key completely breaks GCM. Generate a fresh random IV every time; it doesn't need to be secret — store it next to the ciphertext.
- Never ship the key to the browser. Symmetric keys live server-side (env / KMS), never in
NEXT_PUBLIC_*.
// lib/crypto/aes-gcm.ts — AES-256-GCM via Web Crypto (Node 20+ / Edge / browser)
// `crypto` is the global Web Crypto object; no import needed in Node 20+ / Edge.
const IV_BYTES = 12; // GCM standard; MUST be unique per message under one key
/** Import a 32-byte (256-bit) raw key as an AES-GCM CryptoKey. */
async function importKey(raw: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, [
"encrypt",
"decrypt",
]);
}
/** Encrypt UTF-8 text → { iv, ciphertext } (both base64). GCM tag is appended to ciphertext. */
export async function encrypt(plaintext: string, rawKey: Uint8Array) {
const key = await importKey(rawKey);
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); // fresh every call
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(plaintext),
);
return { iv: b64(iv), ciphertext: b64(new Uint8Array(ct)) };
}
/** Decrypt; throws if the tag fails (tampered/wrong key) — never returns garbage. */
export async function decrypt(
payload: { iv: string; ciphertext: string },
rawKey: Uint8Array,
): Promise<string> {
const key = await importKey(rawKey);
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: unb64(payload.iv) },
key,
unb64(payload.ciphertext),
);
return new TextDecoder().decode(pt);
}
const b64 = (u: Uint8Array) => Buffer.from(u).toString("base64");
const unb64 = (s: string) => Buffer.from(s, "base64");You can bind extra context to the ciphertext with GCM's
additionalData(AAD) — e.g. pass the row'spatient_idas AAD so a ciphertext copied to another row fails to decrypt. Cheap, strong defence against ciphertext-swapping.
Asymmetric: encrypting to a public key
When you need to encrypt for someone without a pre-shared secret, use public-key crypto. OWASP's preference is ECC (Curve25519) over RSA — smaller keys, faster, fewer footguns. If you must use RSA, use RSA-OAEP with ≥2048-bit keys (OAEP padding is mandatory; textbook/PKCS#1 v1.5 is broken in practice).
The cleanest API for "encrypt to a public key" is libsodium sealed boxes (X25519 + XSalsa20-Poly1305). The sender needs only the recipient's public key; libsodium generates a throwaway ephemeral keypair per message, so the ciphertext is anonymous — the recipient can verify integrity but cannot learn who sent it.
// lib/crypto/sealed-box.ts — anonymous public-key encryption with libsodium
import sodium from "libsodium-wrappers";
/** Recipient generates a keypair once; publishes publicKey, keeps privateKey secret. */
export async function newKeypair() {
await sodium.ready;
const { publicKey, privateKey } = sodium.crypto_box_keypair();
return { publicKey, privateKey }; // Uint8Array, 32 bytes each
}
/** Anyone with the recipient's public key can seal a message to them. */
export async function seal(message: string, recipientPublicKey: Uint8Array) {
await sodium.ready;
// ephemeral keypair is generated + erased internally; sender stays anonymous
return sodium.crypto_box_seal(sodium.from_string(message), recipientPublicKey);
}
/** Only the recipient (holding both keys) can open it. */
export async function open(
sealed: Uint8Array,
recipientPublicKey: Uint8Array,
recipientPrivateKey: Uint8Array,
): Promise<string> {
await sodium.ready;
const opened = sodium.crypto_box_seal_open(
sealed,
recipientPublicKey,
recipientPrivateKey,
);
return sodium.to_string(opened);
}Always
await sodium.readybefore anysodium.*call — the WASM module loads asynchronously. Calling early throws.
Hashing: passwords and integrity (not encryption)
Hashing is one-way. Two distinct jobs:
- Password storage → Argon2id. Per OWASP, use Argon2id with ≥19 MiB memory, iterations (t) = 2, parallelism (p) = 1 as a floor (e.g.
m=19456,t=2,p=1). It's memory-hard, so GPU/ASIC cracking is expensive. Fallbacks in order: scrypt (N=2^17, r=8, p=1) when Argon2 is unavailable; bcrypt only for legacy, work factor ≥10 — and note bcrypt silently truncates input at 72 bytes, so enforce a max length. PBKDF2 (600k iterations, HMAC-SHA-256) when FIPS-140 is required. The library salts for you — never write your own salt loop. - Integrity / fingerprinting → SHA-256. For "did this change?" or dedupe keys. Never for passwords (it's too fast — trivially brute-forced).
// lib/crypto/password.ts — server-side ONLY (native binding, never in the browser)
import * as argon2 from "argon2";
export const hashPassword = (plain: string) =>
argon2.hash(plain, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 });
/** Constant-time compare is handled inside verify(); returns boolean, never throws on mismatch. */
export const verifyPassword = (hash: string, plain: string) =>
argon2.verify(hash, plain);With Clerk as the auth provider you usually don't store passwords at all — Clerk owns that. Hash passwords yourself only for systems Clerk doesn't cover.
In transit vs at rest
| In transit | At rest | |
|---|---|---|
| Threat | Network eavesdropper / MITM | Stolen DB dump / backup / disk |
| Mechanism | TLS 1.2+ (HTTPS) | Disk/column/field encryption |
| Who handles it | Platform (Vercel, Cloudflare) — just enforce HTTPS | You (envelope encryption, field-level) |
TLS protects bytes on the wire; it does nothing for a leaked database. PHI/PII needs both: TLS in transit and application-layer encryption at rest. Disk-level encryption (Supabase/Neon at-rest) protects against a stolen disk but not against a compromised app role that can SELECT plaintext — which is why field-level encryption matters below.
Envelope encryption & KMS
You don't encrypt millions of rows with one master key sitting in an env var. The OWASP pattern is envelope encryption:
- A DEK (Data Encryption Key) encrypts the actual data (AES-256-GCM).
- A KEK (Key Encryption Key) — held in a KMS/HSM (AWS KMS, GCP KMS, Cloudflare) — encrypts each DEK.
- You store the wrapped DEK next to the ciphertext. The plaintext KEK never leaves the KMS; KEK and DEK are stored separately.
This means key rotation = re-wrap the DEK (cheap), not re-encrypt all data. A leaked wrapped-DEK is useless without KMS access.
Rotation triggers per OWASP: suspected compromise, end of cryptoperiod, or data-volume thresholds.
PII / PHI masking & field-level encryption
This is the codeAmani core. DoseVault handles PHI under HIPAA; everything we run touches PII under Kenya's KDPA 2019. Disk encryption alone is not enough — once the app connects, the data is plaintext to anyone with a query. Three complementary tools:
1. Field-level (application-layer) encryption
Encrypt sensitive columns with AES-256-GCM before they hit the database, decrypt on read in the app. The DB only ever sees ciphertext, so a leaked dump (or an over-broad RLS hole) exposes nothing. Use a per-field DEK via envelope encryption, and bind the row id as AAD so ciphertext can't be moved between rows.
// Storing a PHI field — encrypt at the app boundary, store {iv, ciphertext}
const { iv, ciphertext } = await encrypt(patient.diagnosis, dek);
await db.from("records").insert({ patient_id, diagnosis_iv: iv, diagnosis_ct: ciphertext });In Postgres/Supabase, prefer this app-layer approach over pgcrypto for PHI: keys stay out of the database entirely, so a DB compromise never yields keys. Reserve pgcrypto/pgsodium for cases where the DB must do the crypto.
2. Data masking
Show a non-reversible partial view for display, logs, and support tools — the real value is never rendered. This is presentation, not security on its own, but it shrinks how often plaintext is exposed.
// Mask an M-Pesa phone (always 254XXXXXXXXX) and an ID for UI / logs
export const maskPhone = (p: string) => p.replace(/^(\d{3})\d{6}(\d{3})$/, "$1******$2"); // 254******149
export const maskId = (id: string) => id.length <= 4 ? "****" : "****" + id.slice(-4);Never log raw PII/PHI or M-Pesa identifiers. Mask
CheckoutRequestID, phone numbers, and patient ids before they reach Sentry or app logs. Structured logging only — noconsole.logof payloads.
3. Tokenization
Replace a sensitive value with a meaningless token; keep the real value in a separate, locked-down vault. Your main DB and analytics store only tokens. Unlike encryption, the token carries no recoverable data — ideal when most systems never need the real value (e.g. an internal ref instead of a phone number across services). It also shrinks PCI/compliance scope, since the sensitive data lives in one auditable place.
E2EE in one paragraph
End-to-end encryption means the server only ever holds ciphertext. For one-shot messages, a sealed box (above) is genuine E2EE: encrypt to the recipient's public key client-side, the server stores opaque bytes, only the recipient's private key opens it. For live, ongoing conversations, Signal's double ratchet adds forward secrecy (a leaked key doesn't expose past messages) and post-compromise security by deriving a fresh key per message — conceptually a key that "ratchets" forward and can't be wound back. Don't implement the ratchet yourself; use libsignal or a vetted library. For DoseVault patient↔clinician messaging, sealed boxes cover the common case without that complexity.
Security checklist
- AES-256-GCM for symmetric at-rest; fresh 12-byte IV per message, never reused.
- Argon2id for passwords (≥19 MiB / t=2 / p=1); SHA-256 only for integrity.
- Never confuse encryption (reversible) with hashing (one-way) — passwords are hashed.
- ECC/Curve25519 or RSA-OAEP ≥2048 for asymmetric; never textbook RSA.
- Keys server-side only — KMS / env, never
NEXT_PUBLIC_*, never in the browser bundle. - Envelope encryption: DEK encrypts data, KMS-held KEK wraps DEK; store them separately.
- Field-level encryption for PHI/PII columns; DB sees ciphertext only.
- Mask PII/M-Pesa ids before logging; never log raw payloads.
- Use audited libraries (Web Crypto, libsodium, @noble, Tink) — never hand-rolled crypto.
- TLS 1.2+ enforced everywhere; HTTPS-only callbacks.
- Rotate keys on compromise / cryptoperiod end; rotation = re-wrap DEK, not re-encrypt all.
codeAmani notes
- PHI (DoseVault) is the bar. HIPAA + KDPA 2019 both demand encryption at rest for health/personal data. Use field-level AES-256-GCM on PHI columns plus envelope encryption — disk-level encryption alone does not satisfy the threat model (a compromised app role still reads plaintext).
- Keys never touch the client. Symmetric/private keys live in Vercel env or a KMS, server-side only. Web Crypto runs in the browser only for E2EE sealed-box flows where the browser legitimately holds the user's own keys — never for shared server keys.
- Mask M-Pesa identifiers. Phone numbers (
254XXXXXXXXX),CheckoutRequestID, and account refs get masked in every log, Sentry event, and support view. Store them encrypted/tokenized if retained. - Prefer audited libs, explicitly. Web Crypto (built-in),
libsodium-wrappers,@noble/ciphers, or Google Tink. OWASP's guidance on custom crypto is blunt: "don't do this." We don't. - TLS is the platform's job, encryption-at-rest is ours. Vercel/Cloudflare terminate TLS; we own field-level encryption, key management, and masking.
- Auth passwords → Clerk. Where Clerk owns auth, we don't store passwords at all. Hash with Argon2id only for the rare system Clerk doesn't cover.
- Tokenize to shrink scope. Keeping raw PII/PHI in a single locked vault and tokens everywhere else reduces both KDPA exposure and PCI scope.