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

@itsmaazmalick/jwecrypt

v0.2.0

Published

Encrypted (JWE) session tokens with per-user key derivation, instant single-session revocation, and instant revoke-all-sessions for suspicious activity.

Downloads

151

Readme

jwecrypt

Encrypted session tokens (JWE, RFC 7516), built as a drop-in replacement for jsonwebtoken when you need the payload to actually be confidential — plus first-class support for revoking a single session or every session for a user instantly, without a database wipe or a token blocklist that grows forever.

import { createTokenService, generateMasterKey, InMemoryStore } from "@itsmaazmalick/jwecrypt";

const tokens = createTokenService({
  masterKeys: { v1: process.env.MASTER_KEK! }, // generateMasterKey() to create one
  currentMasterKeyId: "v1",
  store: new InMemoryStore(),
});

const token = await tokens.issue({ userId: user.id, payload: { role: user.role }, expiresIn: "15m" });
const { payload } = await tokens.verify(token);

await tokens.revokeSession(jti);          // kill one session
await tokens.revokeAllSessions(user.id);  // suspicious activity: kill every session for this user

Why not just JWT?

A JWT's payload is base64url, not encrypted — anyone holding the token (or intercepting it) can read every claim. jwecrypt encrypts the payload with AES-256-GCM, so the token is opaque without the server's key material.

Where this runs

createTokenService/createCsrfProtection are plain functions built on globalThis.crypto — zero framework dependencies, so they work identically wherever the JS/TS runs server-side:

| Environment | Works? | Notes | |---|---|---| | Node.js (any version ≥20) | Yes | Call tokens.issue() / tokens.verify() directly | | Express.js | Yes | Your own middleware | | Fastify | Yes | An onRequest hook | | Nest.js | Yes | A Guard, backed by a DI-injected service | | Next.js — API routes / route handlers (Node runtime) | Yes | Same as Express | | Next.js — Edge middleware / Edge runtime | Yes | WebCrypto-based core, no Node-specific APIs | | Deno, Bun, Cloudflare Workers | Yes | Same WebCrypto-based core everywhere |

The same is true of persistence: RedisRestStore (see Storage) talks to Redis over plain fetch, so revocation state works identically in every row above too, including edge runtimes — no TCP Redis client (which edge runtimes structurally cannot support) required.

Vue, Angular, and Vite-bundled frontend code are a different category — those run in the browser. jwecrypt never runs there, because the master key must never reach the browser. The frontend only ever carries the token (an Authorization header or a cookie); issuing and verifying always happens server-side. If your Vite app has an SSR/API backend, that Node-side code can use jwecrypt like any other backend — the frontend bundle itself never imports it.

How revocation works

  • Master KEK. You provide one root secret (masterKeys). It never appears in a token and never leaves your server.
  • Per-user key, derived, not stored. Every token is encrypted with a key derived via HKDF-SHA256 from (masterKey, userId, keyVersion). Nothing per-user is persisted — the key is recomputed on every issue/verify.
  • Revoke one session: revokeSession(jti) adds that token's id to a short-lived blocklist. Nothing else about the user is touched.
  • Revoke everything (suspicious activity): revokeAllSessions(userId) bumps that user's keyVersion counter by one. Every token issued under the old version now derives to a stale key and is rejected — instantly, with no blocklist scan, and with zero effect on any other user.

This means "log out everywhere" is an O(1) write, not a fan-out delete across every active session row.

Storage

jwecrypt ships two stores.

InMemoryStore — local dev / single-process only

Plain JS Maps, zero setup, works in any JS/TS runtime.

import { InMemoryStore } from "@itsmaazmalick/jwecrypt";

const store = new InMemoryStore();

The tradeoff, stated plainly: state lives only in this process's memory. While that one process stays alive, revocation is fully correct — revokeSession/revokeAllSessions work exactly as designed, and verify() sees every revocation immediately. It is lost on every restart/redeploy/crash, and it is not shared across multiple processes or instances — revoking a session on one instance does nothing on another. On a serverless/edge platform (Vercel, Lambda, Cloudflare Workers, etc.), this limitation is sharper still: those platforms run many separate, ephemeral instances in parallel, each with its own InMemoryStore, so cross-instance revocation is unreliable there. Token issuance and verification themselves are unaffected (that part is stateless) — it's specifically revocation that needs a store every instance can see.

Fine for a single-process app, local development, or a prototype. For anything running more than one instance — which includes essentially all serverless and edge deployments — use RedisRestStore instead.

RedisRestStore — production, edge, and serverless

Backed by Redis, but spoken over HTTP, not a TCP connection. This is a deliberate choice, not an implementation shortcut: edge runtimes (Cloudflare Workers, Vercel Edge Middleware, etc.) have no raw TCP socket API at all, so a conventional Redis client (ioredis, node-redis) simply cannot run there, no matter how it's configured. HTTP is the one transport that works identically in Node, serverless functions, and edge runtimes — and since it rides on the platform's own fetch, this adds zero npm dependencies, matching the rest of jwecrypt.

import { createTokenService, RedisRestStore } from "@itsmaazmalick/jwecrypt";

const store = new RedisRestStore({
  url: process.env.REDIS_REST_URL!,     // e.g. https://xxx.upstash.io
  token: process.env.REDIS_REST_TOKEN!,
  // keyPrefix: "myapp:",               // default "jwecrypt:" — set this if the Redis instance is shared with other data
  // requestTimeoutMs: 5000,            // default 5000 — caps how long a hung Redis can block a request
});

const tokens = createTokenService({
  masterKeys: { v1: process.env.MASTER_KEK! },
  currentMasterKeyId: "v1",
  store,
  sessionStore: store, // RedisRestStore also implements SessionStore
  // In production, set this so verify() doesn't do a network round trip to
  // Redis on every single request — see the tradeoff note below the API section.
  keyVersionCacheTtlMs: 30_000,
});

Works out of the box with:

  • Upstash Redis — the protocol this implements natively; pass its REST URL/token directly.
  • Vercel KV — built on Upstash under the hood, same protocol.
  • Your own Redis (self-hosted, ElastiCache, Memorystore, ...) — put the open-source Serverless Redis HTTP (SRH) proxy in front of it to get the same REST protocol without changing providers.

Every command this store issues is atomic at the Redis level (INCR, or a single SET ... EX), or wrapped in Upstash's /multi-exec transaction when more than one key must change together (session record + its per-user index) — the same "no lost update" contract InMemoryStore gives you, just shared across every instance. See Security notes for how this store defends against command injection, TLS downgrade, and a hung backend.

Bring your own store

Both stores implement the same interfaces — that's the real extension point if you run something else (Postgres, DynamoDB, a conventional TCP Redis client from a long-lived Node server, ...):

interface KeyVersionStore {
  getCurrentKeyVersion(userId: string): Promise<number>; // MUST return 1 for an unknown user
  incrementKeyVersion(userId: string): Promise<number>;   // MUST be atomic
}

interface RevocationStore {
  isRevoked(jti: string): Promise<boolean>;
  revoke(jti: string, ttlSeconds: number): Promise<void>;
}

Four methods total (see src/stores/types.ts for the exact contract each must honor — e.g. incrementKeyVersion must be atomic, or a lost update silently weakens revokeAllSessions). A runStoreContractTests helper (test/stores/contract.ts) codifies that contract — both InMemoryStore and RedisRestStore are verified against it, and reusing it against your own adapter is the recommended way to confirm it's correct before trusting it in production.

Key rotation

masterKeys accepts more than one key:

createTokenService({
  masterKeys: { v1: oldKey, v2: newKey },
  currentMasterKeyId: "v2", // new tokens use v2; tokens still carrying v1 in their header keep verifying
  store,
});

New tokens always encrypt under currentMasterKeyId. Verification looks up whichever key id the token itself references, so already-issued tokens keep working through a rotation. Drop an old id from masterKeys once you're sure nothing still holds a token encrypted under it (i.e. after its longest possible maxTtlSeconds has elapsed).

CSRF protection

CSRF is fundamentally about how you transport the token, not about token encryption itself:

  • Authorization: Bearer <token> from application JS (recommended). A cross-site page cannot read your token (same-origin policy) and cannot attach a custom header to a cross-site request. CSRF is structurally impossible — you don't need anything below.
  • A cookie, attached automatically by the browser. Now a forged cross-site request does carry your cookie, and you need an explicit defense. jwecrypt ships one: a signed double-submit cookie — verifyToken checks an HMAC-SHA256 signature binding the token to a session id (typically the verified token's jti), so a token minted for one session can't be replayed against another, and a value an attacker can't read (because they can't read the cookie) can't be forged either.

Every framework example below does the same three things: mint a CSRF token bound to the session jti at login, set it as a readable (non-httpOnly) cookie, and verify the X-CSRF-Token header against it on every state-changing request. For pre-authentication flows (a login form, before there's a session jti), bind to a separate anonymous per-visit id instead.

Express

import { createCsrfProtection } from "@itsmaazmalick/jwecrypt";

const csrf = createCsrfProtection({ secret: process.env.CSRF_SECRET! }); // separate from masterKeys

app.post("/login", async (req, res) => {
  const token = await tokens.issue({ userId: user.id, payload: { role: user.role } });
  const { jti } = await tokens.verify(token);
  const csrfToken = await csrf.generateToken(jti);

  res.cookie("session", token, { httpOnly: true, sameSite: "strict", secure: true });
  res.cookie("csrf", csrfToken, { sameSite: "strict", secure: true }); // NOT httpOnly
  res.json({ ok: true });
});

function requireCsrf(req, res, next) {
  const header = req.headers["x-csrf-token"];
  csrf.verifyToken(header, req.auth.jti).then((valid) => {
    if (!valid) return res.status(403).json({ error: "CSRF_VALIDATION_FAILED" });
    next();
  });
}

app.post("/transfer", requireAuth, requireCsrf, (req, res) => {
  /* req.auth is trustworthy and req.body wasn't forged cross-site */
});

Fastify

const csrf = createCsrfProtection({ secret: process.env.CSRF_SECRET! });

fastify.decorate("requireCsrf", async (request, reply) => {
  const header = request.headers["x-csrf-token"];
  const valid = await csrf.verifyToken(header, request.auth.jti);
  if (!valid) reply.code(403).send({ error: "CSRF_VALIDATION_FAILED" });
});

fastify.post(
  "/transfer",
  { onRequest: [fastify.requireAuth, fastify.requireCsrf] },
  async (request) => {
    /* request.auth is trustworthy and the request wasn't forged cross-site */
  },
);

Nest.js

import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { createCsrfProtection } from "@itsmaazmalick/jwecrypt";

@Injectable()
export class CsrfGuard implements CanActivate {
  private readonly csrf = createCsrfProtection({ secret: process.env.CSRF_SECRET! });

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const header = request.headers["x-csrf-token"];
    const valid = await this.csrf.verifyToken(header, request.auth.jti);
    if (!valid) throw new ForbiddenException("CSRF_VALIDATION_FAILED");
    return true;
  }
}
// then: @UseGuards(AuthGuard, CsrfGuard) on any mutating route

Next.js (API route / route handler)

import { cookies, headers } from "next/headers";
import { tokens, csrf } from "@/lib/jwecrypt"; // your shared setup

export async function POST(request: Request) {
  const sessionToken = cookies().get("session")?.value;
  const { jti } = await tokens.verify(sessionToken);

  const csrfHeader = headers().get("x-csrf-token");
  if (!(await csrf.verifyToken(csrfHeader, jti))) {
    return Response.json({ error: "CSRF_VALIDATION_FAILED" }, { status: 403 });
  }

  /* handle the request */
}

Frontend — Vue, Angular, React, or plain JS

This half is framework-agnostic — it's the Fetch API and document.cookie, nothing framework-specific to import:

function readCookie(name: string) {
  return document.cookie
    .split("; ")
    .find((row) => row.startsWith(name + "="))
    ?.split("=")[1];
}

export async function transfer(body: unknown) {
  return fetch("/api/transfer", {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-CSRF-Token": readCookie("csrf")! },
    credentials: "include", // send the session + csrf cookies
    body: JSON.stringify(body),
  });
}

Wrap this once (an Angular HttpInterceptor, a Vue composable, a React fetch hook) and every mutating request gets the header automatically — the underlying mechanics never change.

API

createTokenService(config: {
  masterKeys: Record<string, string | Uint8Array>;
  currentMasterKeyId: string;
  store: KeyVersionStore & RevocationStore;
  sessionStore?: SessionStore;       // optional: powers "list my active sessions" UIs
  issuer?: string;
  audience?: string;
  defaultExpiresIn?: number | string; // default "15m"
  maxTtlSeconds?: number;             // hard ceiling, default 30 days; issue() silently clamps to this
  clockToleranceSeconds?: number;     // default 0
  keyVersionCacheTtlMs?: number;      // default 0 (no caching — every verify() sees revocation immediately)
}): {
  issue(params: { userId: string; payload?: object; expiresIn?: number | string; notBefore?: number | string; context?: object }): Promise<string>;
  verify(token: string): Promise<{ payload: object; jti: string; userId: string; keyVersion: number; issuedAtSeconds: number; expiresAtSeconds: number }>;
  revokeSession(jti: string, options?: { ttlSeconds?: number }): Promise<void>;
  revokeAllSessions(userId: string): Promise<void>;
}

createCsrfProtection(config: { secret: string | Uint8Array }): {
  generateToken(sessionId: string): Promise<string>;
  verifyToken(token: string, sessionId: string): Promise<boolean>; // never throws, false on any failure
}

Errors

All thrown errors extend TokenError (.code), so you can branch generically or specifically:

| Class | When | Suggested handling | |---|---|---| | TokenExpiredError | exp has passed | Normal — prompt reauth / silent refresh | | TokenNotYetValidError | nbf is in the future | Reject | | TokenRevokedError (.reason: "session" \| "all-sessions") | Explicitly revoked | Force logout; don't offer silent refresh | | TokenInvalidError | Malformed, tampered, unknown algorithm/master key | Reject, consider logging as a potential attack | | ConfigurationError | Bad createTokenService() config, or a misconfigured RedisRestStore (e.g. non-HTTPS url) | Thrown synchronously at startup, not per-request | | StoreError | A store adapter (e.g. RedisRestStore) couldn't complete an operation — network failure, timeout, backend error | Treat as service-unavailable, not as an authentication failure; retry/alert per your own policy |

keyVersionCacheTtlMs is a deliberate opt-in tradeoff: leaving it at 0 guarantees revocation takes effect on the very next verify() call anywhere; raising it trades some revocation latency for fewer store round trips under high QPS. Also note "instant" revocation is only as strongly consistent as your store's read path.

Security notes

No library can honestly claim to be "100% secure" — security is a property of how a whole system is built, deployed, and operated, not a checkbox a dependency ticks for you. What follows is what jwecrypt itself does, and what stays your responsibility as the integrator.

What's built in:

  • Algorithm is pinned to alg: "dir" / enc: "A256GCM" and enforced by an explicit header check before any cryptographic work happens — a token claiming any other algorithm, or carrying a non-empty encrypted-key segment, is rejected outright (defends against algorithm-confusion attacks).
  • Compression (zip) is never enabled and is explicitly rejected if present in a header, avoiding compression-oracle side channels.
  • Standard claims (exp/iat/nbf/iss/aud/sub) live inside the encrypted payload, not the plaintext header — only uid (user id), kv (key version), kid (master key id), and jti are visible on the wire, and all four are AEAD-authenticated (tampering with any of them invalidates the GCM tag).
  • Master keys and CSRF secrets shorter than 256 bits are rejected at construction time, with a clear error, instead of silently accepted or failing on a raw platform exception.
  • issue() rejects a payload containing __proto__, constructor, or prototype — object spread on those keys reassigns a prototype instead of setting a data property, a classic prototype-pollution shape. This matters most when an app passes something close to raw user input as payload (e.g. payload: req.body).
  • verify() reads a token's header (uid, kv, jti) before any cryptographic check — necessarily, since that's what's used to look up whether the token has been revoked. Because that header is unauthenticated at that point, an attacker can put arbitrary values there; the optional keyVersionCacheTtlMs cache is therefore bounded (FIFO eviction, capped entry count) so flooding verify() with garbage tokens carrying distinct fake uids can't grow server memory without limit.
  • Duration parsing (expiresIn, notBefore) rejects non-finite/overflowing values instead of letting them silently degrade into a broken claim.
  • CSRF token comparison is constant-time; tokens are HMAC-signed and bound to a session id so one session's token can't be replayed against another. verifyToken() resolves to false rather than throwing for any invalid input, including a request that simply omits the CSRF header entirely — the single most common real-world case, and the one every framework example above relies on.
  • InMemoryStore writes nothing to disk, ever — zero risk of a revocation-state file leaking. Its revoke() actively prunes expired entries on every call (not just lazily when that exact jti is re-checked), so a long-running process with steady revocation traffic doesn't accumulate dead entries in memory without bound.
  • All internal errors during decryption collapse into one generic TokenInvalidError message — the specific reason a decrypt failed is never distinguishable from the outside, so there's no oracle for an attacker to probe.
  • Runtime baseline is Node ≥20 using globalThis.crypto directly (no node:crypto, no node:buffer, no dependency of any kind in the portable core), so the same code runs unmodified on Node, Deno, Bun, Cloudflare Workers, and Next.js edge middleware.
  • Zero runtime dependencies. JWE framing (RFC 7516 compact serialization, base64url, AAD binding) is implemented directly against globalThis.crypto.subtle; every actual cryptographic primitive (AES-256-GCM, HKDF-SHA256, CSPRNG) is delegated to the platform's own audited WebCrypto implementation, not reimplemented — this removes an entire third-party dependency (and its transitive supply chain) from your trust boundary without touching how the crypto itself is done.
  • verify() rejects any token over 8KB before doing any base64/JSON work on it — the very first thing checked, ahead of even splitting the string. Without this, an attacker could send a multi-megabyte fake token on every request and force real CPU/memory cost on your server before any cryptographic (or even structural) check has run — a cheap remote resource-exhaustion (DoS) primitive against an unauthenticated endpoint. 8KB is deliberately generous for any realistic session token.
  • RedisRestStore sends every command as a JSON array body (["SET", "key", "value"]), never interpolated into a URL path (/set/key/value). userId/jti are caller-supplied strings jwecrypt does not constrain the charset of; passed as discrete JSON array elements they reach Redis as opaque argument values no matter their content, which rules out command-injection via a crafted user id (there's no path-parsing step that could reinterpret part of a value as a different command or key). Covered by a dedicated test using userIds containing /, :, and quote characters.
  • RedisRestStore requires https:// (plain http:// is rejected unless the host is localhost/127.0.0.1/::1, for local dev only) — the alternative would send your bearer token and revocation/session data in cleartext, a credential-leak and MITM-tampering risk.
  • RedisRestStore aborts any request that doesn't complete within requestTimeoutMs (default 5s) instead of hanging indefinitely — an unreachable or slow Redis backend cannot silently turn every issue()/verify() call into a stalled request.
  • RedisRestStore.recordSession/removeSession write to two Redis keys (the session record and its per-user index) inside an atomic multi-exec transaction, not a plain pipeline — a partial failure can't leave the index and the session record disagreeing with each other.
  • Backend/store failures (network error, timeout, malformed response, a Redis command erroring) throw a distinct StoreError, never silently treated as "not revoked" or "valid" — a store outage fails a request, it does not fail a token open.

Stays your responsibility:

  • Know what InMemoryStore actually guarantees. Revocation is fully correct while your process stays alive, and gone the instant it restarts or you run more than one instance — a redeploy silently un-revokes everything. If a compromised session must stay revoked across restarts, or your app runs more than one instance (this includes almost all serverless/edge deployments), use RedisRestStore or another persistent store — jwecrypt does not default to one.
  • RedisRestStore's token is a bearer credential — anyone who has it can read and write your entire revocation/session state (including forging "not revoked" by deleting keys). Store it exactly like MASTER_KEK: a secret manager or environment variable, never in source control, never logged.
  • Set keyVersionCacheTtlMs to a nonzero value in production when using a network-backed store like RedisRestStore. At the default 0, every single verify() call makes a Redis round trip — correct, but it means your request latency now includes a network hop, and a flood of requests (legitimate or not) becomes a proportional flood against Redis. Caching trades a bounded amount of revocation latency for both.
  • Serve everything over HTTPS. None of the above matters if the token is sent in plaintext over the wire.
  • Store MASTER_KEK / CSRF_SECRET in a real secret manager or environment variable, never in source control (.gitignore already excludes .env).
  • Choose your token transport deliberately (Authorization header vs. cookie — see CSRF section) and rate-limit auth endpoints; jwecrypt verifies tokens, it doesn't throttle requests. This matters more, not less, with a network-backed store — request-level rate limiting is what keeps a flood of verify() calls from becoming a flood against your Redis.
  • jwecrypt has zero runtime dependencies, so there's no third-party JWE/crypto/Redis-client library in your supply chain to keep current. Some dev-only tooling in this repo (vitest's transitive esbuild) has advisories that affect a local dev server we don't run in this project's workflow, but you should still track them (npm audit) if you fork this setup.
  • If you write your own store adapter, run it against the shared contract tests (test/stores/contract.ts) and make sure incrementKeyVersion is genuinely atomic under your database — a lost update there silently weakens revokeAllSessions.

Development

npm install
npm run typecheck
npm test
npm run build
npm run example