Themis vs Web Crypto API: I tested both for encryption in my digital identity app and the tradeoffs aren't obvious
I made a mistake that cost me two weeks of redesign: I assumed Web Crypto API was enough for everything. I assumed it without testing it against my real cases, without measuring, without questioning. I assumed it because "it's native to the browser" and that sounds like a guarantee. I'm not telling you this for catharsis — I'm telling you because it's exactly the kind of silent mistake that destroys a security sprint without anyone noticing until it's way too late.
Context matters: I'm building Lakaut ID, a biometric identity validation system. It's not a notes app. It's not a forms SaaS. It's an Argentine digital Certificate Authority where cryptography isn't just another feature — it's the reason the product exists. When you pick the wrong cryptographic primitive here, you don't lose uptime: you lose the entire chain of trust.
That forced me to do something I should have done from the start: compare Themis (from Cossack Labs) against Web Crypto API on concrete cases, with real code, with real metrics, without letting myself get seduced by either one's marketing.
TypeScript encryption in a production web app: the context that changes everything
There's a trap in how cryptography gets discussed in the JavaScript ecosystem: most posts talk about "encrypting data" as if it were a homogeneous problem. It isn't. In Lakaut ID I have three distinct cases with requirements that don't overlap:
- Biometric data at rest — facial templates, document hashes. I need AES-GCM with user-derived keys, without the server being able to read the plaintext.
- Secure messaging between components — the biometric capture frontend talking to the validation backend on Railway. I need something closer to a secure channel than file encryption.
- Key generation and management — key derivation from user passphrase, rotation, secure export for backup.
Web Crypto API covers all three on paper. Themis too. The problem is in the details.
Web Crypto API: what works well and where it burned me
The API is powerful and well-designed. Living in the browser as a native API has real advantages: zero dependencies, no bundle penalty, and operations are delegated to the runtime implementation (in Node.js 18+ it's the same engine as the browser).
For biometric data at rest, the TypeScript implementation looked like this:
// biometric-encryption.ts — Lakaut ID
// AES-GCM encryption of facial templates before persisting to Railway
async function encryptBiometricTemplate(
templateBuffer: ArrayBuffer,
userKey: CryptoKey
): Promise<{ encrypted: ArrayBuffer; iv: Uint8Array }> {
// Random 12-byte IV — recommended for AES-GCM
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
// Default tagLength: 128 bits — don't change this unless you know what you're doing
tagLength: 128,
},
userKey,
templateBuffer
);
return { encrypted, iv };
}
async function deriveKeyFromPIN(
pin: string,
salt: Uint8Array
): Promise<CryptoKey> {
// Import the PIN as base key material
const baseKeyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(pin),
{ name: "PBKDF2" },
false, // non-extractable — intentional
["deriveKey"]
);
// PBKDF2 with 310,000 iterations — OWASP 2023 recommendation
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 310_000,
hash: "SHA-256",
},
baseKeyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
This worked. It works well. The 310,000 PBKDF2 iterations follow the updated OWASP recommendation and AES-GCM 256 is solid.
The silent problem I almost didn't catch
Web Crypto API has a behavior that cost me dearly: it fails silently in non-HTTPS contexts.
During local development I was testing the biometric capture flow on an internal network VM, without HTTPS. crypto.subtle was available on the global object, but every call returned undefined without throwing exceptions. No console error. No promise rejection. Just: silence.
The spec says crypto.subtle is only available in secure contexts. But the way some browsers handle this — especially on internal networks and Chrome with flags — is inconsistent. I found out when an internal QA reported that "encryption wasn't working" and I couldn't reproduce it from my local machine with localhost (which is a secure context by spec).
The fix was adding an explicit guard:
// crypto-guard.ts — secure context validation before any operation
function assertSecureContext(): void {
if (!window.isSecureContext) {
// Don't throw a generic error — we want to know exactly what happened
throw new Error(
`[Lakaut ID] Cryptographic operation blocked: insecure context. ` +
`Current protocol: ${window.location.protocol}. ` +
`HTTPS or localhost required.`
);
}
if (!crypto.subtle) {
throw new Error(
`[Lakaut ID] crypto.subtle not available in this environment. ` +
`Check browser version and security context.`
);
}
}
This should be mandatory in any app using Web Crypto API. Silent failure is the worst kind of bug in cryptography.
Themis: where it wins and why I added it anyway
Themis from Cossack Labs is a high-level cryptography library. It doesn't expose primitives — it exposes use cases. You don't choose AES-GCM or RSA-OAEP. You choose "SecureCell" (data at rest) or "SecureMessage" (asymmetric messaging) or "SecureSession" (forward-secret channel). The library makes the cryptographic decisions for you.
That's exactly its pitch: reduce the developer error surface.
Installation in the project:
# Themis for Node.js — JS binding of the C/C++ core
npm install jsthemis
# For the frontend (WASM build)
npm install wasm-themis
The bundle penalty is real
I'm not going to lie to you here: adding Themis to Next.js has a cost. The wasm-themis bundle adds approximately 1.2 MB to the client side (before gzip compression, which brings it down to ~400 KB). That's significant.
My decision in Lakaut ID was to not use Themis on the frontend and use Web Crypto API for client-side encryption. Themis lives in the Node.js backend where bundle size doesn't matter.
// backend/channel-encryption.ts — Lakaut ID, Node.js only
// Themis SecureMessage for service-to-service communication
import { SecureMessage } from "jsthemis";
// Each component has its own key pair — generated during setup
const messenger = new SecureMessage(
backendPrivateKey,
frontendPublicKey
);
function encryptValidationResponse(payload: ValidationResult): Buffer {
const serialized = Buffer.from(JSON.stringify(payload));
// Themis chooses the encryption internally — ECDH + AES-GCM under the hood
return messenger.wrap(serialized);
}
function decryptCaptureRequest(message: Buffer): CaptureRequest {
const decrypted = messenger.unwrap(message);
return JSON.parse(decrypted.toString());
}
The advantage I didn't expect: cross-platform portability. Themis has bindings for iOS (Swift/ObjC), Android (Kotlin/Java), Python, and Go. If Lakaut ID ever adds a native mobile app — something that's on the roadmap — the secure messaging protocol between mobile and backend will work without rewriting anything. Web Crypto API on the frontend wouldn't give me that interoperability guarantee.
Forward secrecy: the gap Web Crypto doesn't close easily
For the messaging channel between the biometric capture service and the validation service, I needed forward secrecy: if someone steals the keys today, they can't decrypt last month's traffic.
Web Crypto API has the primitives to build it (ECDH + ephemeral key derivation), but it requires me to implement the full protocol myself. Themis SecureSession gives you that out of the box.
Here's the honest tradeoff: "out of the box" means I trust that Cossack Labs implemented it correctly. Themis is open source, audited, and the repo has a respectable history. But it's still a third-party dependency with everything that implies in terms of supply chain. I've written about supply chain attack vectors in npm and about how npm audit isn't enough to detect them — that applies here too.
My mitigation: jsthemis is pinned to an exact hash in package.json and the update process requires manual changelog review and binary diff.
Benchmark: AES-GCM encryption in Node.js
I measured encryption throughput for 1 MB of simulated biometric data (100 iterations, median):
Environment: Node.js 22.4, Railway (512 MB RAM, 1 shared vCPU)
Dataset: 1 MB ArrayBuffer with random data
Web Crypto API (AES-GCM-256):
Median: 2.1 ms
P95: 3.4 ms
P99: 5.8 ms
Themis SecureCell (Seal mode):
Median: 2.9 ms
P95: 4.2 ms
P99: 7.1 ms
// benchmark-encryption.ts — local measurement script
import { performance } from "perf_hooks";
import { SecureCell } from "jsthemis";
const ITERATIONS = 100;
const PAYLOAD_SIZE = 1024 * 1024; // 1 MB
async function benchmarkWebCrypto(key: CryptoKey): Promise<number[]> {
const times: number[] = [];
const data = crypto.getRandomValues(new Uint8Array(PAYLOAD_SIZE));
for (let i = 0; i < ITERATIONS; i++) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const start = performance.now();
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data);
times.push(performance.now() - start);
}
return times;
}
function benchmarkThemis(key: Buffer): number[] {
const times: number[] = [];
const cell = SecureCell.SealWithSymmetricKey(key);
const data = Buffer.allocUnsafe(PAYLOAD_SIZE);
for (let i = 0; i < ITERATIONS; i++) {
const start = performance.now();
cell.encrypt(data);
times.push(performance.now() - start);
}
return times;
}
Web Crypto wins on raw speed — expected, since it delegates directly to the underlying C++ runtime. Themis has binding overhead but it's marginal for real use cases. For encrypting biometric templates of 10-50 KB (the typical Lakaut ID case), the difference is imperceptible.
The gotchas nobody documents
1. Themis and incomplete TypeScript types
The jsthemis types are out of date on some methods. I found that SecureCell.SealWithSymmetricKey doesn't have overloads for Buffer and Uint8Array declared correctly — I ended up extending the module with a local .d.ts file.
2. Web Crypto API and key export
deriveKey with extractable: false is the right call for production — the key never leaves the secure context. But if you need to back up keys for user recovery, you need extractable: true and an explicit export flow. Mixing both cases in the same code path is a bug factory. In Lakaut ID I separated them into distinct modules with warning comments.
3. Themis in Next.js Edge Runtime
jsthemis has native bindings (N-API). It doesn't work in Next.js Edge Runtime (which runs V8 without native bindings). If you use App Router with export const runtime = 'edge', Themis is off the table. This limited me to using Themis only in API Routes with Node.js runtime, not in middleware.
This kind of runtime incompatibility is the same problem I ran into when reviewing the Clipboard API in TypeScript — APIs that "should work" have contexts where they simply aren't available, and the error isn't always obvious.
4. The attack surface of Themis vs Web Crypto
Web Crypto API is a standardized API with implementations across multiple browsers and runtimes. Bugs are public, the spec is public, implementations are audited by enormous teams. Themis is a C/C++ library with bindings, maintained by a smaller team. The attack surface is different — not necessarily larger, but different.
For the security architecture decisions I make in Lakaut ID, that difference matters. My autonomous agents also have explicit guardrails for exactly this kind of attack surface reasoning.
FAQ: TypeScript encryption in production web apps
Themis or Web Crypto API for a new app in 2026?
Depends on the case. If encryption is browser-only and you don't need interoperability with mobile or non-JS backends, Web Crypto API is enough and you avoid the dependency. If you have a heterogeneous stack (native mobile + Node.js + maybe Python in some microservice), Themis closes the interoperability gap better than any alternative you're going to find.
Is Web Crypto API safe for biometric data?
Yes, if you use it correctly: AES-GCM-256, random IV per operation, PBKDF2 or Argon2 for key derivation, and the secure context guard I mentioned above. The problem isn't the API itself — it's how easy it is to use it wrong.
Can I use Themis on the frontend with Next.js?
Yes, but with wasm-themis (the WebAssembly build), not jsthemis. The bundle cost is ~400 KB gzipped. For a digital identity app where cryptography is core, that tradeoff might be worth it. For a generic SaaS app, probably not.
What if I need forward secrecy in Web Crypto API?
You can build it with ephemeral ECDH and deriveKey, but you have to implement the full protocol yourself. It's doable, it's documented, and if you do it right it works. Themis SecureSession gives you that packaged up. The cost of Themis is the dependency; the cost of the custom implementation is the risk of getting it wrong.
How do you handle key rotation in production?
In Lakaut ID I have a separate key management process: at-rest data keys have a version ID embedded in the ciphertext. When I rotate keys, the decryption service knows which version to use for each record. There's no mass re-encryption — only records accessed post-rotation get re-encrypted with the new key. It's a design decision with its own tradeoffs, but it avoids a costly migration operation.
Is the operational overhead of Themis worth it compared to the conceptual overhead of Web Crypto API?
That's the honest question. Web Crypto API requires you to understand cryptography well enough not to make subtle mistakes (IV reuse, wrong parameters, key material handling). Themis requires you to trust Cossack Labs and manage a C/C++ dependency with its bindings. Neither one is actually "easy." In Lakaut ID I use both: Web Crypto for the frontend, Themis for the backend. It's not elegant, but it's honest about the tradeoffs.
My thesis, no beating around the bush
Web Crypto API is enough for 80% of web use cases. It's solid, well-specified, and doesn't add third-party attack surface. But it has two real limits that matter to me in Lakaut ID: cross-platform interoperability for when we eventually get to native mobile, and the complexity of correctly implementing forward secrecy without abstractions.
Themis closes those gaps. The price you pay is a heavier bundle on the frontend (if you use it there) and a C/C++ dependency that requires careful management in the backend — especially in a context where I've already written about how supply chain attacks in npm are more dangerous than they look.
The uncomfortable thing nobody says: there is no "secure by default" option in applied cryptography. Every choice you make — primitive, library, parameter, execution context — is a decision that can be right or wrong depending on context. I spent two weeks learning that the expensive way. Now I know it.
If you're building something where cryptography actually matters, test both options against your concrete cases before deciding. Don't trust generic benchmarks or blog posts — including this one. Test with your data, in your runtime, in your infrastructure.
Original sources:
- Themis — Cossack Labs: https://github.com/cossacklabs/themis
Related Articles
pnpm vs npm vs yarn vs bun: The Real Comparison Nobody Gives You in 2025
I used all four in real projects. One wrecked a monorepo at 3am. Another saved my ass in production. Here's the unfiltered truth about every major package manager in 2025.
Functional Programming in TypeScript: I Applied It to My Real Codebase — Here's What Survived (and What Didn't)
I applied functors, monads, and pipe() from fp-ts to my real Next.js codebase with Server Actions and Prisma. I documented which patterns survived production and which ones got buried in the README. The gap between clean examples and real code is enormous — and nobody documents it honestly.
Spring Boot in Real Production: What My Lakaut Codebase Taught Me That the Official Docs Leave Out
Three years running Spring Boot in real production at Lakaut AC left me with logs, incidents, and metrics no tutorial will ever show you. The official docs assume an environment that doesn't exist on Railway with real JVM tuning.
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.