DX Stack, Libraries & Testing
Integrating a centralized IdP — an Identity Provider, the service that issues your
tokens — tempts you toward a pile of helper libraries and a bespoke “Auther SDK.”
Resist it. The whole integration is roughly two hundred lines of jose plus a
webhook handler, and the smaller you keep the surface, the less there is to drift out
of sync with Auther.
This page is the minimal kit to run this in production without overbuilding: the libraries worth depending on, the package layout that keeps the code in one place, a copy-paste Hono middleware factory, a test strategy that never needs a running Auther, the signals to emit, and a feature flag that lets you roll out — and roll back — safely.
The library set
Section titled “The library set”This is the smallest set that gets you production-ready. Most of these you already have in a Hono monorepo; the rest are focused, tree-shakable dependencies rather than frameworks.
| Purpose | Library | Why |
|---|---|---|
| JWT verification + JWKS cache | jose | Auto-rotating, tree-shakable |
| Web framework | Hono (you already have it) | — |
| Webhook signature | node:crypto (stdlib) | Constant-time compare, no deps |
| Webhook idempotency | ioredis / Upstash Redis | SETNX with TTL |
| Claim schema validation | zod (you already have it) | Catches Auther schema drift |
| OIDC client (SPA/web) | oidc-client-ts, oauth4webapi | Avoid rolling your own PKCE |
| Mobile OIDC | AppAuth (iOS/Android) | Platform-native |
A shared integration package
Section titled “A shared integration package”In the Hono monorepo, consolidate every piece of integration code into a single
package so that apps/web, apps/server, and your cron workers all import from one
place. Keeping verification, shadow-user creation, and the webhook router together
means a change to the claim shape lands in exactly one file.
Directorypackages/
Directoryauther-client/
Directorysrc/
- verify.ts JWKS +
verifyAutherToken - shadow.ts
ensureShadowUser(parameterized over db/table) - webhook.ts reusable webhook router factory
- hono.ts
authContextMiddlewarefactory for Hono - admin.ts
autherAdmin(banUser,updateUser,impersonate) - types.ts
AutherClaims,WebhookEvent - env.ts env schema fragment
- verify.ts JWKS +
- package.json
Each consumer pulls only what it needs: apps/server imports hono, webhook, and
admin; apps/web imports verify if it does its own token verification; cron jobs
import admin.
A copy-paste Hono middleware factory
Section titled “A copy-paste Hono middleware factory”This is the centerpiece — copy this into any Hono app and you have server-side
auth. Every authenticated request flows through one function: it reads the Bearer
token, verifies it against the JWKS (the published set of Auther’s signing keys),
resolves the matching local user — or just-in-time creates a shadow user, a thin
local row mirroring an Auther identity — and stashes both the user and a derived
session on the Hono context for the rest of the request.
It is generic over your User type (note the autherAuth<User> signature), so it
drops into any schema without the package needing to know your tables:
import { createMiddleware } from "hono/factory";import type { AutherClaims } from "./types";import { verifyAutherToken } from "./verify";
export interface AutherMiddlewareOptions<User> { requireAuth?: boolean; getUser: (sub: string) => Promise<User | null>; createUser: (claims: AutherClaims) => Promise<User>;}
export function autherAuth<User>(opts: AutherMiddlewareOptions<User>) { return createMiddleware(async (c, next) => { const header = c.req.header("Authorization"); if (!header?.startsWith("Bearer ")) { if (opts.requireAuth) return c.json({ error: "unauthenticated" }, 401); c.set("user", null); c.set("session", null); return next(); } try { const claims = await verifyAutherToken(header.slice(7)); const user = (await opts.getUser(claims.sub)) ?? (await opts.createUser(claims)); c.set("user", user); c.set("session", { id: claims.sid ?? claims.sub, userId: claims.sub, permissions: claims.permissions ?? {}, abacRequired: claims.abac_required ?? {}, expiresAt: new Date(claims.exp * 1000), }); return next(); } catch { if (opts.requireAuth) return c.json({ error: "invalid token" }, 401); c.set("user", null); c.set("session", null); return next(); } });}The three highlighted regions are the whole story:
- No
Bearertoken → ifrequireAuth, reject with401; otherwise set a null user and continue (so public routes still work). verifyAutherToken→ validates signature, issuer, audience, and expiry. Throw on failure and thecatchmirrors the no-token branch — same null-or-401policy.- Resolve, then provision →
getUser(claims.sub) ?? createUser(claims)is the just-in-time shadow-user step: the first time an Auther identity hits your API, you mint its local row. After that the lookup hits. - Derive the session → the verified claims are the session.
permissionsandabac_requiredcome straight off the token;exp(seconds since epoch) becomes a millisecondDate. No server-side session store — nothing to invalidate.
Wiring it into the app is two lines — pass your own data-access functions for resolving and creating the user:
app.use("/api/*", autherAuth({ getUser: (id) => db.query.users.findFirst({ where: eq(users.id, id) }), createUser: (claims) => ensureShadowUser(claims),}));Testing without a live Auther
Section titled “Testing without a live Auther”The test strategy splits along the same seams as the code. Unit tests should never reach the network; integration tests get a real token from a containerized Auther; webhook tests forge their own signed payloads.
Unit tests mint their own tokens. Generate a local RS256 keypair, sign test
tokens with jose.SignJWT, and stub createRemoteJWKSet to return your local public
key. That gives you deterministic fixtures with no dependency on a running server. A
small makeTestToken helper keeps the call sites clean:
import { SignJWT, generateKeyPair, exportJWK } from "jose";
const { privateKey, publicKey } = await generateKeyPair("RS256");const jwk = await exportJWK(publicKey);jwk.kid = "test-key-1";// In test setup, stub createRemoteJWKSet to return { keys: [jwk] }
export async function makeTestToken(claims: Partial<AutherClaims>) { return new SignJWT({ sub: "usr_test", permissions: { "user": ["read"] }, ...claims }) .setProtectedHeader({ alg: "RS256", kid: "test-key-1" }) .setIssuedAt().setExpirationTime("1h") .setIssuer(process.env.AUTHER_ISSUER!).setAudience(process.env.AUTHER_AUDIENCE!) .sign(privateKey);}Integration tests stand up the real thing: a docker-compose with Auther, your
Hono app, and Postgres. Seed an admin in Auther via script, then obtain a real token
through the OIDC password grant — enabled for test clients only — and exercise your
endpoints with it.
Webhook tests build the raw body and its HMAC locally and POST to
/api/auth/webhooks/auther. Don’t rely on a running Auther to emit events; that
couples your test to Auther’s delivery path when all you want to verify is your own
handler.
Observability
Section titled “Observability”Emit these signals from Hono (via apps/server/src/middlewares/otel.ts or similar)
so you can see verification cost, just-in-time provisioning, and webhook health at a
glance:
auth.jwt.verify.duration_ms(histogram)auth.jwt.verify.result.count{result=ok|expired|signature_bad|kid_unknown}auth.shadow.jit.count{action=insert|existing}auth.webhook.receive.count{type, result}auth.webhook.dedup.count
Every authenticated request should log { user_id, path, status, duration_ms, auth_source }. Wrap each of the three risky operations — JWT verification,
just-in-time shadow insert, and webhook dispatch — in its own OpenTelemetry span, and
have the span record the error on a failed verify or failed JIT.
A feature flag for rollout
Section titled “A feature flag for rollout”Gate the whole integration behind a single environment variable,
env.AUTH_MODE, with three values that control which path the middleware takes:
legacy— your existing local auth, untouched.dual— accept both, so you can migrate sessions and catch regressions.auther— Auther tokens only.
This gives you an instant rollback. If something explodes after cutover, flip the env
back to dual or legacy and redeploy — no code change, no revert.