Encryption
@tekir/encryption provides AES-256-GCM authenticated encryption using the Web Crypto API. No external dependencies, just platform crypto. Works in Bun, Node.js 18+, browsers, and edge runtimes.
Overview
The EncryptionProvider registers an Encryption instance in the DI container, reading the key from config('app.key'). Access it via #services:
import { service } from '@tekir/core'
import type { Encryption } from '@tekir/encryption'
export const encryption = service<Encryption>('encryption')// Access via #services (recommended)
import { encryption } from '#services'
// Or construct directly without DI
import { Encryption } from '@tekir/encryption'
const encryption = new Encryption('my-explicit-app-key')There are four methods:
encrypt(value): JSON-serialize any value, then encrypt it. Returns a base64 string.decrypt(ciphertext): decrypt and JSON-deserialize back to the original value.encryptString(text): encrypt a raw string without JSON wrapping.decryptString(ciphertext): decrypt back to a raw string.
APP_KEY Requirement
All encryption operations require an APP_KEY. The key is never used directly as a cipher key; it is passed through PBKDF2 to derive a 256-bit AES key. The key is validated when the Encryption instance is constructed: it must be at least 16 bytes long and contain enough distinct characters, so trivial keys like "a" or a short repeated string are rejected with a descriptive error rather than silently accepted. A 32+ character random string or a base64-encoded random value both work well:
# Generate a key and write it to .env:
tekir generate:key
# Or if you already have a .env, the command adds/replaces APP_KEY automaticallyValidate the key at boot time using @tekir/env so the application fails fast if APP_KEY is missing:
import { defineEnv, str } from '@tekir/env'
export default defineEnv({
APP_KEY: str() // required, boot fails if missing
})The EncryptionProvider reads the key from config('app.key'), so make sure your config/app.ts exposes it:
import env from '#env'
export default {
name: env.APP_NAME,
key: env.APP_KEY, // EncryptionProvider reads config('app.key')
port: env.PORT
}Encrypting Values
encryption.encrypt(value) accepts any JSON-serializable value, objects, arrays, strings, numbers, booleans. It serializes the value to JSON, encrypts the result, and returns a base64-encoded ciphertext string safe for storage in cookies, database columns, or URL parameters:
import { encryption } from '#services'
// Serialize any JSON-serializable value to JSON, then encrypt it
const token = await encryption.encrypt({ userId: 1, role: 'admin' })
// => 'base64-encoded ciphertext string'
// Store in a cookie, database column, URL query string, etc.// Encrypt complex objects, arrays, nested objects, primitives all work
const payload = await encryption.encrypt({
userId: user.id,
permissions: ['read', 'write'],
expiresAt: new Date().toISOString()
})
// Encrypt a number
const encryptedId = await encryption.encrypt(42)
// Encrypt an array
const encryptedList = await encryption.encrypt([1, 2, 3])Each call produces a different ciphertext even for identical inputs because a fresh random salt and IV are generated per encryption. This means you cannot compare ciphertexts to determine whether two values are equal. Ciphertext written before this change still decrypts: the format is versioned and the legacy layout is detected automatically, so no data migration is required.
Encrypting Strings
Use encryptString() when you have a plain string and do not want the JSON wrapper. This is slightly more efficient and avoids the extra quote characters that JSON.stringify adds around string values:
import { encryption } from '#services'
// encryptString() skips JSON serialization: use it when the value is already a string
const encrypted = await encryption.encryptString('raw text value')
// => 'base64-encoded ciphertext'Decrypting
encryption.decrypt(ciphertext) reverses encrypt(): it decrypts the ciphertext and parses the JSON back into the original value. Pass a TypeScript type parameter for a typed return:
import { encryption } from '#services'
// Decrypt and deserialize: type parameter is inferred or explicit
const data = await encryption.decrypt<{ userId: number; role: string }>(token)
console.log(data.userId) // 1
console.log(data.role) // 'admin'
// Throws if the ciphertext was tampered with or decrypted with the wrong key
try {
const result = await encryption.decrypt(suspiciousCiphertext)
} catch (err) {
// '[@tekir/encryption] Decryption failed: the key may be wrong or
// the ciphertext may have been tampered with.'
}encryption.decryptString(ciphertext) reverses encryptString(), it returns the raw decrypted string without JSON parsing:
import { encryption } from '#services'
// decryptString() returns the raw string: no JSON parsing
const original = await encryption.decryptString(encrypted)
// => 'raw text value'
// Use decryptString() when you encrypted with encryptString()
// Use decrypt() when you encrypted with encrypt()
// Mixing the two will produce a valid decrypt but the result may not parse as JSONA common pattern is encrypting sensitive data before writing it to a cookie and decrypting it on each subsequent request:
import type { HttpContext } from '@tekir/core'
import { encryption } from '#services'
// Encrypt a value before writing it to a cookie
export async function savePreferences({ body, response }: HttpContext) {
const encrypted = await encryption.encrypt(body)
return response
.cookie('prefs', encrypted, { httpOnly: true, sameSite: 'lax' })
.ok({ ok: true })
}
// Decrypt when reading it back
export async function getPreferences({ request, response }: HttpContext) {
const raw = request.cookie('prefs')
if (!raw) return response.ok({})
const prefs = await encryption.decrypt(raw)
return response.ok(prefs)
}Custom Instance
If you need to encrypt with a different key, for example in a multi-tenant setup or in tests, construct an Encryption directly. The PBKDF2 key derivation is cached on the instance, so create it once and reuse it:
import { Encryption } from '@tekir/encryption'
// Pass an explicit key instead of reading from config
const encryption = new Encryption('my-explicit-app-key')
const encrypted = await encryption.encrypt({ secret: 'data' })
const decrypted = await encryption.decrypt(encrypted)
// The key is derived via PBKDF2 on first use and cached for the lifetime of the instance
// Instantiate once and reuse: do not create a new instance per requestAES-256-GCM Internals
The encryption scheme is straightforward and auditable. No proprietary serialization formats or padding schemes are involved beyond what the Web Crypto API provides:
// Output format (v1): base64( version[1 byte] || salt[16 bytes]
// || IV[12 bytes] || AES-256-GCM-ciphertext )
//
// Encryption (per call):
// 1. Generate a random 16-byte salt and a random 12-byte IV
// 2. Derive a 256-bit AES key from APP_KEY + salt via PBKDF2 (SHA-256)
// 3. AES-256-GCM encrypt the plaintext with the derived key and IV
// 4. Concatenate version || salt || IV || ciphertext and base64-encode it
//
// Because the salt is random per message, encrypting the same value twice
// produces different keys and different ciphertext.
//
// Decryption (per call):
// 1. base64-decode the input
// 2. Read the version byte, then the salt and IV; remainder is ciphertext
// 3. Re-derive the key from APP_KEY + salt and AES-256-GCM decrypt
// (the GCM authentication tag is verified automatically)
// 4. Throw if decryption fails (wrong key or tampered ciphertext)
//
// Backwards compatible: ciphertext produced before the versioned format has
// no version byte and is still decrypted using the legacy derived salt.
// New encryptions always emit the v1 format; no data migration is required.Because AES-GCM includes an authentication tag, any modification to the ciphertext after encryption causes decryption to fail with a clear error. You do not need to add a separate MAC.