# tsndr/cloudflare-worker-jwt: a zero-dependency JWT library sized for Cloudflare Workers

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

**tsndr/cloudflare-worker-jwt** — A lightweight JWT implementation with ZERO dependencies for Cloudflare Workers.

- Repository: https://github.com/tsndr/cloudflare-worker-jwt
- Stars: 877 · Forks: 63
- Language: TypeScript
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/tsndr-cloudflare-worker-jwt

## 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:

```typescript
import jwt from "@tsndr/cloudflare-worker-jwt"

async () => {

    // Create a token
    const token = await jwt.sign({
        sub: "1234",
        name: "John Doe",
        email: "john.doe@gmail.com",
        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: "john.doe@gmail.com", ... }
}
```

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.

## 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().

## FAQ

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

## Sources

- [Issues](https://github.com/tsndr/cloudflare-worker-jwt/issues)
- [License: MIT](https://github.com/tsndr/cloudflare-worker-jwt/blob/main/LICENSE)
- [README](https://github.com/tsndr/cloudflare-worker-jwt/blob/main/README.md)
- [Releases](https://github.com/tsndr/cloudflare-worker-jwt/releases)
- [tsndr/cloudflare-worker-jwt on GitHub](https://github.com/tsndr/cloudflare-worker-jwt)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/tsndr-cloudflare-worker-jwt
