Documentation
JWT
Kynetra FX includes a zero-dependency HS256 JWT implementation built on the Web Crypto API. It runs natively on Cloudflare Workers, Node.js, Bun, and Deno without any npm packages — no jsonwebtoken, no polyfills.
Signing tokens#
signJwt(payload, secret, options?) produces a compact HS256 JWT string. The payload is any JSON-serialisable object. The secret is a raw string; the library imports it with the Web Crypto HMAC algorithm internally.
import { signJwt } from '@kynetra/fx-auth' const token = await signJwt( { sub: user.id, tenantId: org.id, roles: ['member'] }, env.JWT_SECRET, { expiresInSec: 3600, issuer: 'api.acme.com', audience: 'app.acme.com' })// -> "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."signJwt options
| Name | Type | Description |
|---|---|---|
| expiresInSec | number | Seconds from now before the token expires. Sets the exp claim. |
| notBeforeSec | number | Seconds from now before the token becomes valid. Sets the nbf claim. |
| issuer | string | Value for the iss claim. Verified on verifyJwt when provided there too. |
| audience | string | Value for the aud claim. Verified on verifyJwt when provided there too. |
| subject | string | Explicit sub claim. Overrides any sub in the payload object. |
| now | () => number | Override the current time (seconds). Useful in tests with a fixed clock. |
Verifying tokens#
verifyJwt(token, secret, options?) returns a discriminated union — inspect valid before accessing the payload. This avoids the common mistake of trusting a token before checking the signature.
import { verifyJwt } from '@kynetra/fx-auth' const result = await verifyJwt(token, env.JWT_SECRET) if (!result.valid) { // result.reason: 'malformed' | 'signature' | 'payload' | 'expired' | 'not-yet-valid' return ctx.json({ error: result.reason }, { status: 401 })} const { sub, tenantId, roles } = result.payloadverifyJwt options
| Name | Type | Description |
|---|---|---|
| now | () => number | Override the current time (seconds) for exp / nbf validation. |
Failure reasons
malformed— not a valid three-part JWT or header/payload are not valid base64url JSON.signature— HMAC verification failed; the token was tampered with or signed with a different secret.payload— the decoded payload is not a valid JSON object.expired— theexpclaim is in the past.not-yet-valid— thenbfclaim has not been reached yet.
The jwtAuth strategy#
jwtAuth(options) is a strategy factory intended for use with auth() or requireAuth(). It reads the token from the Authorization header (Bearer <token> by default), calls verifyJwt internally, and converts the payload into a Principal.
import { requireAuth, jwtAuth } from '@kynetra/fx-auth' app.use( requireAuth([ jwtAuth({ secret: env.JWT_SECRET, // optional overrides: header: 'authorization', // which request header to read scheme: 'Bearer', // scheme prefix to strip toPrincipal: (payload) => ({ // custom payload -> Principal mapping id: payload.sub as string, type: 'user' as const, roles: (payload.roles as string[]) ?? [], scopes: (payload.scopes as string[]) ?? [], tenantId: payload.tenantId as string | undefined, claims: payload, }), }), ]))jwtAuth strategy options
| Name | Type | Description |
|---|---|---|
| secret | string | HMAC secret used to verify the signature. |
| header | string | Request header name. Defaults to "authorization". |
| scheme | string | Token scheme prefix. Defaults to "Bearer". Set to "" to read the raw header value. |
| now | () => number | Override the current time for exp / nbf validation. |
| toPrincipal | (payload) => Principal | Custom function mapping a verified JWT payload to a Principal. Defaults to mapping sub to id and roles/scopes claims. |
Standard JWT claims#
Kynetra FX respects the standard registered claim names. You can include any additional custom claims in your payload — they pass through untouched.
sub— subject; mapped toprincipal.idby the defaulttoPrincipal.iss— issuer; set viasignJwtissueroption.aud— audience; set viasignJwtaudienceoption.exp— expiry (Unix timestamp); derived fromexpiresInSec.nbf— not-before (Unix timestamp); derived fromnotBeforeSec.iat— issued-at; set automatically bysignJwt.
Token refresh pattern#
A common SaaS pattern: issue short-lived access tokens and longer-lived refresh tokens. The access token is verified on every request; the refresh token is only checked on the /auth/refresh route.
import { signJwt, verifyJwt } from '@kynetra/fx-auth' // Issue tokens at loginapp.post('/auth/login', async (ctx) => { const { email, password } = await ctx.jsonBody<{ email: string; password: string }>() const user = await authenticateUser(email, password) const accessToken = await signJwt( { sub: user.id, roles: user.roles, tenantId: user.tenantId }, env.JWT_SECRET, { expiresInSec: 900 } // 15 minutes ) const refreshToken = await signJwt( { sub: user.id, type: 'refresh' }, env.REFRESH_SECRET, { expiresInSec: 60 * 60 * 24 * 30 } // 30 days ) return ctx.json({ accessToken, refreshToken })}) // Refresh access tokensapp.post('/auth/refresh', async (ctx) => { const { refreshToken } = await ctx.jsonBody<{ refreshToken: string }>() const result = await verifyJwt(refreshToken, env.REFRESH_SECRET) if (!result.valid) { return ctx.json({ error: 'invalid_refresh_token' }, { status: 401 }) } const accessToken = await signJwt( { sub: result.payload.sub as string }, env.JWT_SECRET, { expiresInSec: 900 } ) return ctx.json({ accessToken })})Tip
Edge-native design#
The entire JWT implementation uses only crypto.subtle from the Web Crypto API — available natively in all modern JS runtimes. There are no npm dependencies and no Node.js-specific APIs. A Cloudflare Worker using signJwt / verifyJwt adds zero bytes to its bundle beyond what Kynetra FX itself weighs.
Note