@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
Maintainers
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 userWhy 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'skeyVersioncounter 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 —
verifyTokenchecks an HMAC-SHA256 signature binding the token to a session id (typically the verified token'sjti), 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 routeNext.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 — onlyuid(user id),kv(key version),kid(master key id), andjtiare 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 apayloadcontaining__proto__,constructor, orprototype— 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 aspayload(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 optionalkeyVersionCacheTtlMscache is therefore bounded (FIFO eviction, capped entry count) so floodingverify()with garbage tokens carrying distinct fakeuids 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 tofalserather 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. InMemoryStorewrites nothing to disk, ever — zero risk of a revocation-state file leaking. Itsrevoke()actively prunes expired entries on every call (not just lazily when that exactjtiis 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
TokenInvalidErrormessage — 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.cryptodirectly (nonode:crypto, nonode: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.RedisRestStoresends every command as a JSON array body (["SET", "key", "value"]), never interpolated into a URL path (/set/key/value).userId/jtiare 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.RedisRestStorerequireshttps://(plainhttp://is rejected unless the host islocalhost/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.RedisRestStoreaborts any request that doesn't complete withinrequestTimeoutMs(default 5s) instead of hanging indefinitely — an unreachable or slow Redis backend cannot silently turn everyissue()/verify()call into a stalled request.RedisRestStore.recordSession/removeSessionwrite to two Redis keys (the session record and its per-user index) inside an atomicmulti-exectransaction, 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
InMemoryStoreactually 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), useRedisRestStoreor another persistent store — jwecrypt does not default to one. RedisRestStore'stokenis 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 likeMASTER_KEK: a secret manager or environment variable, never in source control, never logged.- Set
keyVersionCacheTtlMsto a nonzero value in production when using a network-backed store likeRedisRestStore. At the default0, every singleverify()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_SECRETin a real secret manager or environment variable, never in source control (.gitignorealready 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 sureincrementKeyVersionis genuinely atomic under your database — a lost update there silently weakensrevokeAllSessions.
Development
npm install
npm run typecheck
npm test
npm run build
npm run example