@alikhalilll/nuxt-crypto
Symmetric encryption for Nuxt 3 / 4 built on the native Web Crypto API. Defaults to AES-256-GCM with PBKDF2-SHA256 key derivation.
- Framework-agnostic core —
createCryptoService()works anywhereSubtleCryptoexists (browser, Deno, Bun, Node 20+). - Key caching — derived keys are cached per salt, so bulk decrypt stays fast even at 100k+ PBKDF2 iterations.
- Pluggable algorithms — swap the default AES-GCM implementation for your own
CryptoAlgorithmwithout touching the payload envelope. - Versioned payload format —
v1.{salt}.{iv}.{cipher}with clean forward compatibility. - Server-only mode — opt into registering the plugin only on the server so the passphrase never ships to the browser bundle.
- Device fingerprint (new) — optional HttpOnly-cookie-based binding so ciphertext becomes undecryptable outside the browser that created it, while still surviving IP changes.
Install
$pnpmadd@alikhalilll/nuxt-cryptoRegister the module
.env (not committed):
Basic round-trip
$crypto is typed as CryptoService via auto-generated module augmentation.
Live · AES-GCM round-trip
hello from the docsEncrypt a JSON object
encrypt takes a string — stringify structured data first.
A tiny composable keeps this ergonomic:
Persist to a cookie
Key cache
PBKDF2 is deliberately slow. The built-in cache (default size 64) keys derived material by salt, so a second decrypt of any payload you've already touched is essentially free.
Error handling
| Scenario | Message |
|---|---|
Payload isn't a.b.c.d | Invalid payload format — expected 4 dot-separated segments. |
| A segment is empty | Invalid payload format — one or more segments were empty. |
| Algorithm version mismatch | Unsupported payload version: v2 (algorithm expects v1). |
| Wrong passphrase / tampered ciphertext | Native OperationError from Web Crypto. |
| Passphrase not set in module config | [nuxt-crypto] passphrase is required. |
Server-only mode
Set serverOnly: true to skip the client plugin and keep the passphrase out of the browser bundle.
With this enabled, $crypto is undefined on the client. Use it in Nitro routes, server-only plugins, or <script setup> blocks guarded by import.meta.server.
Nitro routes
Nitro event handlers run outside the Nuxt app context — useNuxtApp() (and $crypto) is not available there. Use the framework-agnostic core and cache it in server/utils/:
Device fingerprint
Bind a payload to the browser that created it — a copy of the ciphertext in another browser or on another device will refuse to decrypt. Useful for short-lived CSRF tokens, one-time magic links, anti-replay nonces, or any flow where a stolen token must be worthless off-origin.
The fingerprint is built from an HttpOnly device-ID cookie (not the client IP), so it survives network changes — Wi-Fi → 4G, cell handoffs, VPN rotations, laptop sleeps — while still blocking copy-paste to a different browser or device.
Setup
Add a fingerprint salt to your runtime config — a long random secret, server-side only:
Encrypt with a fingerprint (server side)
On the first request for a browser, getClientFingerprint sets an HttpOnly cookie (__nuxt_crypto_device) with a random 32-byte device ID. Subsequent calls reuse it, so the returned fingerprint is stable per browser.
Decrypt with a fingerprint (server side)
What survives, what doesn't
| Scenario | Still decrypts? |
|---|---|
| Wi-Fi → 4G on the same device | ✅ yes — cookie travels |
| Cell tower handoff | ✅ yes |
| Laptop sleeps, rejoins a new Wi-Fi | ✅ yes |
| VPN exit node changes | ✅ yes |
| User copies token to another browser | ❌ no — no cookie there |
| Token exfiltrated via XSS to attacker's box | ❌ no — HttpOnly cookie unreachable |
| User clears cookies | ❌ no — device ID regenerates |
deriveFingerprint — bring your own device ID
If you already have a stable per-browser identifier (a session cookie, a signed JWT sub claim, a row in your devices table), skip the helper cookie entirely:
Binding ciphertext to a fingerprint is not appropriate for long-lived user data. If the device cookie is cleared or the session rotates, those payloads become undecryptable — permanently. Use this for tokens the user can afford to lose: short sessions, magic links, one-shot nonces.
Customizing the cookie
Defaults: httpOnly: true, sameSite: 'lax', path: '/', secure auto-detected from the request protocol, maxAge = 1 year.
Framework-agnostic core
Custom algorithm
Replace AES-GCM with any cipher by implementing CryptoAlgorithm. The payload envelope is preserved; the version tag routes decrypt to the right implementation.
Rotating the passphrase
Payload format
v1.{saltB64}.{ivB64}.{cipherB64} — four dot-separated segments, each standard base64.
| Segment | Bytes | Notes |
|---|---|---|
v1 | — | Algorithm / version tag. |
| salt | 16 | Per-encryption PBKDF2 salt. |
| iv | 12 | AES-GCM initialization vector. |
| cipher | N | Ciphertext + 16-byte GCM auth tag. |
Module options
| Option | Type | Default | Purpose |
|---|---|---|---|
passphrase | string | '' | Passphrase to derive the AES key from. Throws at use if empty. |
provideName | string | '$crypto' | Injected under $<name>. Leading $ is stripped. |
iterations | number | 100_000 | PBKDF2 iteration count. |
keyCacheSize | number | 64 | Max derived keys kept in memory. Set to 0 to disable caching. |
serverOnly | boolean | false | When true, plugin runs only on the server. |