← All plugins

@green-tea/jwt 26.9.0-beta.0

Loads JWT signing keys at boot and publishes a sign/verify registry into the graph.

npx jsr add @green-tea/jwt

Needs @green-tea/core@26.9.0-beta.2 or newer.

Green Tea

@green-tea/jwt

Sign and verify, as one node in the graph.

Keys load once, while providers boot — a bad key fails the deploy,
not the first sign-in of the day.


Loads JWT signing keys at boot and publishes them into the graph as one registry. A handler asks for it with @needs('jwt') and signs or verifies by key name.

Runtimes

Runtime Supported Why not
Node ✅
Deno ✅
Bun ✅
workerd (edge) ❌ Reads the signing key from a file path, and workerd has no filesystem.

Install

npx jsr add @green-tea/jwt

Or deno add jsr:@green-tea/jwt, or bunx jsr add @green-tea/jwt. Under npm, yarn and pnpm jsr add installs through npm.jsr.io, so the import below stays @green-tea/jwt. There is no npmjs.org package — npm i @green-tea/jwt will not find one.

Usage

import { createApp } from '@green-tea/core';
import { jwt } from '@green-tea/jwt';

const app = createApp({
  modules: [AuthModule],
  plugins: [
    jwt({
      keys: {
        access: { path: '/etc/keys/access.json', settings: { issuer: 'api', maxAge: '15m' } },
        refresh: { path: '/etc/keys/refresh.json', settings: { maxAge: '7d' } },
      },
    }),
  ],
});

Signing belongs in the handler — it is the response to that one request:

@Post('/signin')
async signIn(@needs('jwt') jwt: JwtRegistry) {
  return { token: await jwt.get('access').sign({ sub: user.id }) };
}

Verifying does not. It is a precondition shared by every protected route, and its result is what those routes are actually asking for, so it belongs in a @Step:

@Step({ provides: 'user', needs: ['jwt', 'req'] })
class Authenticate {
  async run(ctx) {
    const header = ctx.req.headers.authorization ?? '';
    if (!header.startsWith('Bearer ')) throw new Unauthorized('missing bearer token');

    // verify() throws on a bad signature, a wrong issuer or an expired token, which cuts the
    // request before the handler — the handler only ever sees a token that was good.
    return { user: await ctx.jwt.get('access').verify(header.slice(7)) };
  }
}

@Get('/me')
me(@needs('user') user: Claims) {
  return user;
}

The handler asks for user, never for jwt, and green-tea runs Authenticate only for the routes that do — the route table says which ones, rather than a matcher on a middleware chain.

Keys are read once, while providers boot. A missing or malformed key therefore fails app.listen() — or await app.boot() on Deno and Bun — instead of the first sign-in of the day. Call app.boot() before Deno.serve or Bun.serve; without it, the failure surfaces on every request instead.

Two instances need two graph names: jwt({ provides: 'serviceJwt', keys }) publishes under serviceJwt, and the plugin takes that name too.