tsndr/cloudflare-worker-jwt: a zero-dependency JWT library sized for Cloudflare Workers
A lightweight JWT implementation with ZERO dependencies for Cloudflare Workers.
At a glance
- What is it?
- A TypeScript JSON Web Token implementation that bundles to a single ESM file with no runtime dependencies, built for the edge. It signs with HS256 by default, returns undefined instead of throwing on a bad token, and ships no revocation mechanism.
- Who is it for?
- Adopt cloudflare-worker-jwt when the Worker is the only place tokens are minted and checked, the payload is small, and the app already speaks HS256 or can hand over a Web Crypto key. Do not adopt it for a Node service, for a system that needs revocation, or anywhere a JWT library is expected to handle key rotation and JWKS, because the README documents none of that and the algorithm list includes none.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 9 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Why a Workers developer needs a separate JWT library
The package states its own constraint in the first line of the README: a lightweight JWT implementation with ZERO dependencies for Cloudflare Workers. That constraint decides most of what follows. The dependency list in package.json holds only devDependencies, among them esbuild, typescript, vitest and the Cloudflare worker types, so nothing from a package manager ends up inside a deployed Worker.
The intended reader is someone who has put a Worker at the front door of an API and now has to issue and check a JSON Web Token. Signing, expiry checks and key handling are the exact pieces every edge handler rewrites for itself, so the library reduces the job to one import and two calls. The second audience is teams that have a policy of shipping no third party runtime code to an edge runtime, and for them the zero dependency claim is the entire procurement argument.
sign() defaults to HS256 and returns a token string
Signing starts with a payload object and a secret. The README gives the call shape as sign(payload, secret, [options]). The secret argument accepts a string, a JsonWebKey or a CryptoKey, which are the key shapes a Worker can hold, and the third argument carries the algorithm, defaulting to HS256. The shortest useful call therefore needs two arguments.
The return value matters as much as the inputs: a token string rather than an object, because the token is the thing handed to a client and stored in an Authorization header. The same call can carry options.header to extend the header of the resulting token, which is how a custom typ or a key identifier gets attached.
The list of available algorithms is much wider than the default: ES256, ES384, ES512, HS256, HS384, HS512, RS256, RS384, RS512, and none. That last entry is a sharp edge rather than a convenience. An unsigned token offers no integrity guarantee, so a verifier that trusts the alg value carried inside the token can be handed one that carries none at all.
verify() hands back undefined instead of raising on a bad token
Verification is where the design shows its trade-off most clearly. verify(token, secret, [options]) returns the decoded token or undefined. By default the library throws only integration errors, meaning failures of the call itself, and reports token level problems by returning undefined. The README names the four errors that surface when options.throwError is set to true: ALG_MISMATCH, NOT_YET_VALID, EXPIRED and INVALID_SIGNATURE.
For a Worker this is a defensible choice. The common path becomes a null check rather than a try/catch, which keeps a handler short on a platform where start-up latency is part of the design. The price is that the specific reason a token was rejected is not handed back, and a caller that must tell an expired token apart from a forged one has to opt into throwError and catch those error names by hand.
A second knob covers the same family of problem: options.clockTolerance takes a number of seconds and exists to help with slightly out of sync systems, letting a token that expired moments ago still pass on a worker whose clock trails the issuer. The default is 0, so no slack is granted unless you ask for it.
Bounding a token with nbf and exp in the payload
Time limits are ordinary payload fields here rather than a separate options object. To set them, put nbf and/or exp into the payload when signing, both as seconds since the epoch. The README's restrict timeframe example makes a token valid from one hour ahead and expiring two hours ahead:
import jwt from "@tsndr/cloudflare-worker-jwt"
async () => {
// Create a token
const token = await jwt.sign({
sub: "1234",
name: "John Doe",
email: "[email protected]",
nbf: Math.floor(Date.now() / 1000) + (60 * 60), // Not before: Now + 1h
exp: Math.floor(Date.now() / 1000) + (2 * (60 * 60)) // Expires: Now + 2h
}, "secret")
// Verify token
const verifiedToken = await jwt.verify(token, "secret")
// Abort if token isn't valid
if (!verifiedToken)
return
// Access token payload
const { payload } = verifiedToken
// { sub: "1234", name: "John Doe", email: "[email protected]", ... }
}Verification then applies those two checks, which is where NOT_YET_VALID and EXPIRED come from. The example also shows the shape of the result: a header object with alg and typ keys, and a payload object carrying the custom claims, the sub, the name and the email.
What the library does not offer is a way to take a token back. There is no revocation list and no block list in the API, so once a token is signed and unexpired it stays usable. A bounded lifetime is the only lever the documentation exposes.
decode() parses a token without touching the signature
decode(token) does one thing: it parses. The README says plainly that it returns the decoded token without verifying it, and repeats the point that verify() should be used when verification is intended. The returned object has the same shape as a successful verify, with header and payload keys, and in the documented example the header carries alg and typ.
The risk here is easy to introduce during a refactor, because decode and verify take the same token string and return something that looks the same to the caller. A handler that reaches for decode to read a subject claim and then trusts a separate guard elsewhere has two places to keep in sync, and the cheap one is the one that gets used. If a token is going to be trusted, the verify path is the one that produces a usable answer, and the fact that it can return undefined is part of that contract rather than an inconvenience.
One bundled ESM file, no runtime packages, MIT terms
The packaging is unusually plain, and it says the most about what the project is for. package.json sets type to module, points exports at ./index.js, lists index.js and index.d.ts in files, and declares an engine of node >=18 for the tooling rather than for the runtime. The build script runs tsc and then bundles with esbuild, targeting esnext on the neutral platform and writing the output to index.js from src/index.ts.
A prepack script runs that build before packing, so the published artifact is a single JavaScript file with its type declarations and nothing else to resolve at run time. That is the mechanism behind the zero dependency claim: nothing has to be installed into the Worker bundle except this package.
The repository layout adds tests/, a tsconfig.json and a vite.config.ts, with vitest as the test runner. The devDependencies include @edge-runtime/vm, which points the test setup at an edge shaped runtime rather than plain Node, and @cloudflare/workers-types for the platform's own type surface.
Licensing is MIT, a permissive choice, and a common precondition for teams that want a dependency code to sit inside a deployed Worker without extra obligations. The same file pins the version at 3.2.3, matching the most recent release listed, v3.2.3 dated 2026-08-13, after v3.2.2 on 2026-07-26 and v3.2.1 on 2025-12-13.
The alternative: the Web Crypto API already in the runtime
The alternative worth naming is the Web Crypto interface a Worker already has, and the type signatures show this is deliberate rather than accidental. The secret argument accepts a JsonWebKey or a CryptoKey, the key objects the platform's crypto interface works with, and the algorithm list is built from the HMAC, RSA and ECDSA families that same interface can operate on.
Assembling the token yourself over that interface means doing the base64url encoding of the header and the payload, performing the signature step, and writing the timestamp comparison logic, then arriving at the same three-part string the library hands back. The trade runs the other direction as well. Working directly against the platform gives you control over the exact token bytes and lets you reuse a key you already hold, while the library gives you a fixed contract: HS256 by default, the same argument order everywhere, and one set of error names.
Going the other way, toward a general purpose JSON Web Token package built for Node, means carrying a dependency tree into a runtime this project was written to avoid. If the Worker is the only place tokens are minted and checked, the library is the smaller commitment. If several services in different runtimes must agree on token format, the assumption behind this one weakens.
Editorial conclusion
Adopt cloudflare-worker-jwt when the Worker is the only place tokens are minted and checked, the payload is small, and the app already speaks HS256 or can hand over a Web Crypto key. Do not adopt it for a Node service, for a system that needs revocation, or anywhere a JWT library is expected to handle key rotation and JWKS, because the README documents none of that and the algorithm list includes none. Before shipping, check three things in the source: that the lifetime you need fits the nbf and exp fields expressed in seconds, that throwError is set wherever a silent undefined would hide an expiry, and that no handler calls decode() in place of verify().
Frequently asked questions
How do I install cloudflare-worker-jwt in a Cloudflare Worker project?
The README's install section gives one command: npm i @tsndr/cloudflare-worker-jwt. The package publishes as an ESM module, since package.json sets type to module and exports ./index.js, and the example imports it with a default import from "@tsndr/cloudflare-worker-jwt".
Which JWT algorithms does cloudflare-worker-jwt support?
The available algorithms are ES256, ES384, ES512, HS256, HS384, HS512, RS256, RS384, RS512 and none. The default for both sign and verify is HS256, and the secret argument accepts a string, a JsonWebKey or a CryptoKey.
Why does verify() return undefined instead of throwing for an expired token?
By default the library throws only integration errors and reports token level failures by returning undefined. Setting options.throwError to true makes it throw ALG_MISMATCH, NOT_YET_VALID, EXPIRED or INVALID_SIGNATURE as well.
Does cloudflare-worker-jwt pull in any runtime dependencies?
No. The description calls it a zero dependency implementation, and package.json lists only devDependencies such as esbuild, typescript and vitest. The published files are index.js and index.d.ts, with a prepack script that runs the build before packing.
Can I read a token payload without verifying the signature?
Yes, decode(token) returns the header and payload without verifying the token. The README states that verify() should be used if you intend to verify it as well.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/tsndr-cloudflare-worker-jwt)Was this useful?