TILLAUTH · SDK · NODE

@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).

01Install

Install

bash
pnpm add @tillstack/auth-node
02Verify

The verifier

ts
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 with code='wrong_app'.
  • apiBase — override the API base. Defaults to https://auth.tilldev.dev.
  • issuer — accept exactly one issuer. Once the app signs with its own key, pin https://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'.

03Adapters

Express middleware

ts
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 })
})
04Adapters

Fastify hook

ts
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,
}))
05Adapters

Workers · Bun · Deno · plain fetch

In a fetch-shaped runtime (Workers, Bun, Deno), call verifyHeader directly. It throws a TillAuthVerifyError whose code says why:

ts
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
    }
  },
}
07Advanced

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:

ts
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.

08Performance

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 kid miss 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.