@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_pub_…',
// apiBase defaults to https://auth.tilldev.dev; override for a custom auth domain.
})
// Anywhere on the hot path:
const claims = await auth.verify(accessToken)
// claims: { sub, app, email, mfa, iat, exp }First call fetches the JWKS from <apiBase>/.well-known/jwks.json; thereafter it's cached and revalidated on kid miss. Verification is offline once the cache is warm — no per-request round trip to TillAuth.
Constructor options
appId— your TillAuth public ID. Required. Tokens for a different app are rejected withcode='wrong_app'.apiBase— override the API base. Defaults tohttps://auth.tilldev.dev.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_pub_…' })
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_pub_…' })
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
createFetchHandler returns a (req: Request) => Promise<Response | null> you can drop into any fetch-shaped runtime. It enforces verification and returns null on success (so you fall through to your handler):
import { createVerifier, createFetchHandler } from '@tillstack/auth-node'
const auth = createVerifier({ appId: 'tau_pub_…' })
const requireAuth = createFetchHandler(auth)
export default {
async fetch(req) {
const failed = await requireAuth(req)
if (failed) return failed // 401 with typed code
const claims = (req as any).user // attached by the handler
return new Response(JSON.stringify({ user: claims.sub }))
},
}Inline keys (air-gapped)
If your verifier can't reach TillAuth at startup, fetch the JWKS once and pass it in:
const keys = JSON.parse(process.env.TILLAUTH_PUBLIC_JWKS!) // [{ kid, kty, crv, x }]
const auth = new TillAuthVerifier({ appId: 'tau_pub_…', publicJwks: keys })Rotation is your responsibility in this mode — re-deploy with the new JWKS during the 24-hour overlap window.
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 (key rotation), JWKS is re-fetched lazily. The 24-hour overlap window means you should never see this happen on a hot token.
Browser side: @tillstack/auth-react.