Kynetra FX

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.

src/auth/tokens.ts
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

NameTypeDescription
expiresInSecnumberSeconds from now before the token expires. Sets the exp claim.
notBeforeSecnumberSeconds from now before the token becomes valid. Sets the nbf claim.
issuerstringValue for the iss claim. Verified on verifyJwt when provided there too.
audiencestringValue for the aud claim. Verified on verifyJwt when provided there too.
subjectstringExplicit sub claim. Overrides any sub in the payload object.
now() => numberOverride 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.

src/auth/verify.ts
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.payload

verifyJwt options

NameTypeDescription
now() => numberOverride 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 — the exp claim is in the past.
  • not-yet-valid — the nbf claim 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.

src/app.ts
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

NameTypeDescription
secretstringHMAC secret used to verify the signature.
headerstringRequest header name. Defaults to "authorization".
schemestringToken scheme prefix. Defaults to "Bearer". Set to "" to read the raw header value.
now() => numberOverride the current time for exp / nbf validation.
toPrincipal(payload) => PrincipalCustom 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 to principal.id by the default toPrincipal.
  • iss — issuer; set via signJwt issuer option.
  • aud — audience; set via signJwt audience option.
  • exp — expiry (Unix timestamp); derived from expiresInSec.
  • nbf — not-before (Unix timestamp); derived from notBeforeSec.
  • iat — issued-at; set automatically by signJwt.

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.

src/routes/auth.ts
import { signJwt, verifyJwt } from '@kynetra/fx-auth'
 
// Issue tokens at login
app.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 tokens
app.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

Use different secrets for access and refresh tokens. If an access token is leaked, the refresh secret remains uncompromised and you can rotate it independently.

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

For API key authentication (opaque tokens stored in your database) or signed cookie sessions, see API Keys & Sessions.
JWT · Kynetra FX