npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@alikhalilll/nuxt-crypto

v1.3.0

Published

Symmetric Web Crypto service for Nuxt 3/4. AES-256-GCM + PBKDF2 with key caching, pluggable algorithms, and optional device-fingerprint binding.

Downloads

325

Readme

@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 corecreateCryptoService() works anywhere SubtleCrypto exists (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 CryptoAlgorithm without touching the payload envelope.
  • Versioned payload formatv1.{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) — bind a payload to the browser that created it via an HttpOnly cookie. Survives IP changes (Wi-Fi ↔ 4G, VPN rotation) while still blocking copy-paste to another browser or device.
  • Strong typesCryptoService, CryptoServiceConfig, and CryptoAlgorithm all exported.

Table of contents

  1. Install
  2. Register the module
  3. Usage
  4. Server-only mode
  5. Nitro / API routes
  6. Device fingerprint
  7. Framework-agnostic core
  8. Custom algorithm
  9. Rotating the passphrase (re-encryption)
  10. Payload format
  11. Module options reference
  12. Exported types

Install

pnpm add @alikhalilll/nuxt-crypto

Register the module

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@alikhalilll/nuxt-crypto'],
  crypto: {
    // Always wire this from an env var. Never commit the passphrase.
    passphrase: process.env.NUXT_ENCRYPTION_PASSPHRASE ?? '',
    provideName: '$crypto', // leading "$" optional
    iterations: 100_000,
    keyCacheSize: 64,
    serverOnly: false,
  },
});

.env (not committed):

NUXT_ENCRYPTION_PASSPHRASE=replace-me-with-a-long-random-secret

Usage

Basic round-trip

<script setup lang="ts">
const { $crypto } = useNuxtApp();

const payload = await $crypto.encrypt('super-secret');
// payload: "v1.<saltB64>.<ivB64>.<cipherB64>"

const plain = await $crypto.decrypt(payload);
</script>

$crypto is typed as CryptoService via auto-generated module augmentation.

Encrypt / decrypt a JSON object

encrypt takes a string, so stringify structured data first:

const session = { userId: 42, roles: ['admin'], issuedAt: Date.now() };

const payload = await $crypto.encrypt(JSON.stringify(session));

const restored = JSON.parse(await $crypto.decrypt(payload)) as typeof session;

A tiny helper makes this ergonomic:

// composables/useEncrypted.ts
export function useEncrypted() {
  const { $crypto } = useNuxtApp();
  return {
    encode: async <T>(value: T) => $crypto.encrypt(JSON.stringify(value)),
    decode: async <T>(payload: string) => JSON.parse(await $crypto.decrypt(payload)) as T,
  };
}

Encrypt / decrypt an arbitrary value

const { encode, decode } = useEncrypted();

const token = await encode({ refresh: 'abc123', exp: Date.now() + 3_600_000 });
const { refresh, exp } = await decode<{ refresh: string; exp: number }>(token);

Persist a ciphertext in a cookie

const cookie = useCookie<string | null>('session');

// Write
cookie.value = await $crypto.encrypt(JSON.stringify({ userId: 42 }));

// Read
if (cookie.value) {
  const { userId } = JSON.parse(await $crypto.decrypt(cookie.value));
}

Bulk encrypt / decrypt (key cache benefits)

PBKDF2 is deliberately slow. The built-in key cache (default size 64) keys derived material by salt, so a second decrypt of any payload you've already touched is essentially free:

const items = Array.from({ length: 100 }, (_, i) => `item-${i}`);

const payloads = await Promise.all(items.map((i) => $crypto.encrypt(i)));

// Fast — each salt was just derived during encrypt, so decrypt hits the cache.
const plain = await Promise.all(payloads.map((p) => $crypto.decrypt(p)));

Clear the key cache

$crypto.clearKeyCache();

Useful when:

  • You rotate the passphrase (the cache would still hold old keys).
  • You want to force re-derivation in a security-audit test.

Error handling

try {
  await $crypto.decrypt('not-a-real-payload');
} catch (e) {
  // '[nuxt-crypto] Invalid payload format — expected 4 dot-separated segments.'
  console.error((e as Error).message);
}

Errors you can see:

| 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 in module options to skip the client plugin entirely — the passphrase never ends up in the browser bundle.

crypto: {
  passphrase: process.env.NUXT_ENCRYPTION_PASSPHRASE ?? '',
  serverOnly: true,
}

With this enabled, $crypto is undefined on the client. Use it only in:

  • Nitro server routes (server/api/*.ts)
  • Server-only plugins
  • <script setup> blocks guarded by import.meta.server

Nitro / API routes

Nitro event handlers run outside the Nuxt app context, so useNuxtApp() — and therefore $crypto — isn't available there. Use the framework-agnostic core directly. A tiny server utility keeps the service cached across requests:

// server/utils/crypto.ts
import { createCryptoService } from '@alikhalilll/nuxt-crypto/core';

let servicePromise: ReturnType<typeof createCryptoService> | null = null;

export function useServerCrypto() {
  if (!servicePromise) {
    servicePromise = createCryptoService({
      passphrase: process.env.NUXT_ENCRYPTION_PASSPHRASE!,
      iterations: 100_000,
    });
  }
  return servicePromise;
}

Encrypt data before sending it to the browser, decrypt it when it comes back:

// server/api/session/encode.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const crypto = await useServerCrypto();
  return { token: await crypto.encrypt(JSON.stringify(body)) };
});

// server/api/session/decode.post.ts
export default defineEventHandler(async (event) => {
  const { token } = await readBody(event);
  const crypto = await useServerCrypto();
  return JSON.parse(await crypto.decrypt(token));
});

Device fingerprint

Bind a payload to the browser that created it, so a copy of the ciphertext in another browser or on another device won't decrypt. Useful for short-lived CSRF tokens, magic links, and anti-replay nonces.

The fingerprint is built from an HttpOnly device-ID cookie — not the client IP — so it survives Wi-Fi → 4G switches, cell handoffs, VPN rotations, and residential IP rotation while still blocking copy-paste to another origin.

Setup

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Server-side pepper. Keep this secret; never expose to the client.
    cryptoFingerprintSalt: process.env.NUXT_CRYPTO_FINGERPRINT_SALT ?? '',
  },
});
# .env
NUXT_CRYPTO_FINGERPRINT_SALT=<64 random hex chars>

Encrypt / decrypt with a fingerprint

// server/api/session.post.ts
import { getClientFingerprint } from '@alikhalilll/nuxt-crypto/server';

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const crypto = await useServerCrypto(); // see "Nitro / API routes" above

  const fingerprint = await getClientFingerprint(event, {
    salt: useRuntimeConfig().cryptoFingerprintSalt,
  });

  return {
    token: await crypto.encrypt(JSON.stringify(body), { fingerprint }),
  };
});

The first call sets an HttpOnly cookie (__nuxt_crypto_device) with a random 32-byte ID; subsequent calls reuse it. Pass the same { fingerprint } to crypto.decrypt on the server to round-trip.

What this protects against

| Scenario | Decrypts? | | ----------------------------------------- | ------------------------------------ | | Wi-Fi → 4G on the same device | ✅ yes — cookie travels with browser | | VPN exit node or ISP IP rotation | ✅ yes | | Copy token to another browser on same box | ❌ no — no cookie in that browser | | XSS-exfiltrated token to an attacker | ❌ no — HttpOnly cookie unreachable | | User clears cookies | ❌ no — permanently unrecoverable |

deriveFingerprint — bring your own device ID

If you already manage per-device identity (session table, signed JWT, etc.), skip the cookie helper:

import { deriveFingerprint } from '@alikhalilll/nuxt-crypto/server';

const fingerprint = await deriveFingerprint({
  deviceId: session.id,
  salt: useRuntimeConfig().cryptoFingerprintSalt,
});

Warning — a fingerprinted payload becomes undecryptable if the device cookie is cleared or the session rotates. Use only for tokens the user can afford to lose (short sessions, magic links, one-shot nonces), never for long-lived data.

Framework-agnostic core

Everything the Nuxt plugin wraps is available as a plain factory:

import { createCryptoService } from '@alikhalilll/nuxt-crypto/core';

const service = await createCryptoService({
  passphrase: process.env.ENC_PASS!,
  iterations: 100_000,
  keyCacheSize: 64,
});

const payload = await service.encrypt('hi');
const clear = await service.decrypt(payload);
service.clearKeyCache();

Works in Node 20+, Bun, Deno, or any browser. Passing a custom subtle: SubtleCrypto is supported for testing.

Custom algorithm

Replace AES-GCM with any cipher by implementing CryptoAlgorithm. The payload envelope ({version}.{salt}.{iv}.{cipher}) is preserved, and the version tag routes decrypt to the right algorithm.

import { createCryptoService } from '@alikhalilll/nuxt-crypto/core';
import type { CryptoAlgorithm } from '@alikhalilll/nuxt-crypto/types';

const myAlgo: CryptoAlgorithm = {
  version: 'v2',
  async deriveKey({ subtle, passphrase, salt, iterations }) {
    /* ... */
  },
  async encrypt({ subtle, key, plainText }) {
    /* ... */
  },
  async decrypt({ subtle, key, cipher, iv }) {
    /* ... */
  },
};

const service = await createCryptoService({
  passphrase: 'p4ss',
  algorithm: myAlgo,
});

Old v1 payloads will cleanly fail decrypt under a v2 service (the version check throws). Build a version router yourself if you need mixed-version reads during a rotation.

Rotating the passphrase (re-encryption)

import { createCryptoService } from '@alikhalilll/nuxt-crypto/core';

const oldService = await createCryptoService({ passphrase: OLD_PASS });
const newService = await createCryptoService({ passphrase: NEW_PASS });

async function rotate(payload: string): Promise<string> {
  const plain = await oldService.decrypt(payload);
  return newService.encrypt(plain);
}

Run rotate over your stored ciphertexts during a background migration. Clear the key cache on whichever service goes out of use.

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 reference

| 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. |

Exported types

import type {
  CryptoService,
  CryptoServiceConfig,
  CryptoOperationOptions,
  CryptoAlgorithm,
  CryptoModuleOptions,
  Bytes,
  ParsedPayload,
} from '@alikhalilll/nuxt-crypto/types';

// Server subpath (Nitro / H3 only)
import type { ClientFingerprintOptions, FingerprintParts } from '@alikhalilll/nuxt-crypto/server';
import { getClientFingerprint, deriveFingerprint } from '@alikhalilll/nuxt-crypto/server';

License

MIT