@tillstack/auth-node
A JWT verifier that validates tokens against your app's public signing keys (JWKS-cached), Express middleware, Fastify hook, a generic fetch-handler for Workers / Bun / Deno, and a cookie-mode proxy for HttpOnly sessions. No native dependencies — the core is Web Crypto, so it runs on edge runtimes unchanged (Workers guide).
Install
pnpm add @tillstack/auth-nodeThe verifier
import { TillAuthVerifier } from '@tillstack/auth-node'
const auth = new TillAuthVerifier({
appId: 'tau_…', // the public id; the app id works too
})
// Anywhere on the hot path:
const claims = await auth.verify(accessToken)
// claims: { sub, app, pid, email, mfa, iat, exp }The verifier picks the key set from the token's issuer. A token signed with the app's own key is checked against <apiBase>/realms/<appId>/.well-known/jwks.json, which holds only this app's keys; a token from an app still on the shared key is checked against <apiBase>/.well-known/jwks.json. Both are cached and re-fetched on a kid miss, so verification is offline once the cache is warm.
Constructor options
appId— your TillAuth public ID (the publishable key or the app id works too). Required. Tokens for a different app are rejected withcode='wrong_app'.apiBase— override the API base. Defaults tohttps://auth.tilldev.dev.issuer— accept exactly one issuer. Once the app signs with its own key, pinhttps://auth.tilldev.dev/realms/<app id>to refuse anything the shared key signed. Without it, the verifier accepts the shared issuer and this app's own, never another app's.publicJwks— inline an array of JWK keys. Use this for air-gapped verification where the verifier can't reach TillAuth at startup.requireVerifiedEmail— reject tokens whose user hasn't completed email verification. Defaults to false.requireMfa— reject tokens whose primary signin wasn't MFA-challenged. Defaults to false.
Errors
TillAuthVerifyError with a typed code: 'no_token', 'invalid_token', 'wrong_app', 'mfa_required', 'email_not_verified'.
Express middleware
import express from 'express'
import { TillAuthVerifier, expressMiddleware } from '@tillstack/auth-node'
const auth = new TillAuthVerifier({ appId: 'tau_…' })
const app = express()
// Protect a route group:
app.use('/api', expressMiddleware(auth, {
onError: (err) => ({ error: err.message, code: err.code }), // optional
}))
// In handlers:
app.get('/api/me', (req, res) => {
// req.user is the typed AuthClaims object
res.json({ user_id: req.user.sub, email: req.user.email })
})Fastify hook
import fastify from 'fastify'
import { TillAuthVerifier, fastifyHook } from '@tillstack/auth-node'
const app = fastify()
const auth = new TillAuthVerifier({ appId: 'tau_…' })
app.addHook('preHandler', fastifyHook(auth))
app.get('/api/me', async (req) => ({
user_id: req.user.sub,
email: req.user.email,
}))Workers · Bun · Deno · plain fetch
In a fetch-shaped runtime (Workers, Bun, Deno), call verifyHeader directly. It throws a TillAuthVerifyError whose code says why:
import { createVerifier, TillAuthVerifyError } from '@tillstack/auth-node'
const auth = createVerifier({ appId: 'tau_…' })
export default {
async fetch(req: Request) {
try {
const claims = await auth.verifyHeader(req.headers.get('authorization'))
return Response.json({ user: claims.sub })
} catch (err) {
if (err instanceof TillAuthVerifyError) return Response.json({ error: err.code }, { status: 401 })
throw err
}
},
}Inline keys (air-gapped)
If your verifier can't reach TillAuth at startup, fetch the app's key set once from https://auth.tilldev.dev/realms/<public id>/.well-known/jwks.json and pass it in:
const keys = JSON.parse(process.env.TILLAUTH_PUBLIC_JWKS!) // [{ kid, kty, crv, x }]
const auth = new TillAuthVerifier({ appId: 'tau_…', publicJwks: keys })Rotation is your responsibility in this mode. A rotation publishes the new key 15 minutes before it signs and the previous key keeps verifying for 24 hours after, so update the inline keys when you rotate.
Performance notes
- Verification is ~150μs after the JWKS cache is warm. Cold-path adds the first JWKS fetch (one round trip, then cached).
- The verifier is safe to share across requests — no per-request state.
- On a
kidmiss the key set is re-fetched. New keys are published 15 minutes before they sign, so a warm cache usually has the key already.
Browser side: @tillstack/auth-react.