Skip to content

Edge Cases Catalog

Once the happy path works, the integration’s real cost lives in the corners: two requests racing to create the same shadow user, a webhook that never lands, a JWT that outlives the account it belongs to. This page is a scannable catalog of thirty such cases, grouped by when they bite.

How to use this page: skim the group headings to find the phase you’re in, then read the case whose first line matches the symptom you’re seeing. Each entry leads with the trigger and follows with the fix; examples are specific to the Hono template where possible.

These bite when two writers touch the same shadow user, or when the local row drifts from Auther’s copy of the truth.

Two requests arrive simultaneously for Alice, whose shadow row doesn’t exist yet. Both run just-in-time provisioning, both try to INSERT. ON CONFLICT DO NOTHING followed by a re-SELECT resolves it cleanly: one writer wins, the other reads the winner’s row. No deadlock, no 500.

user.created fires the instant Alice signs up, and she may not have hit the Hono server yet. The webhook handler INSERTs with ON CONFLICT DO NOTHING, which is idempotent against JIT provisioning. Whichever path runs first wins, and both produce the same row.

The common ordering: Alice signs in, hits Hono, and JIT creates her shadow row from the JWT claims. The user.created webhook arrives seconds later and its UPDATE is idempotent — it fills in any fields JIT couldn’t derive (Auther may include fields in the webhook payload that never appear in the JWT).

A webhook can be lost outright: the endpoint was down during a deploy, the QStash queue was exhausted, the idempotency cache was wiped mid-retry, or your receiver 500’d on a bug and burned through its retries. Defend in layers — QStash already retries roughly three times with exponential backoff, you can manually replay a single event (DEL webhook:auther:<id> in Redis, then re-trigger from Auther’s admin UI), and a nightly reconciliation job sweeps the gap:

apps/server/src/workers/reconcile-users.ts
// run via cron service
export async function reconcileUsers() {
const localUsers = await db.select().from(users);
for (const local of localUsers) {
const remote = await fetchAutherUser(local.id); // /api/admin/users/:id
if (!remote) {
await db.delete(users).where(eq(users.id, local.id));
continue;
}
if (remote.email !== local.email || remote.name !== local.name || remote.emailVerified !== local.emailVerified || (remote.image ?? null) !== local.image) {
await db.update(users).set({
email: remote.email, name: remote.name, emailVerified: remote.emailVerified, image: remote.image,
}).where(eq(users.id, local.id));
}
}
}

An admin “resync” button can trigger the same routine for a single user on demand.

Alice changes her email in Auther. Auther sends verification to the new address, and on verify a user.updated webhook fires with the new email and emailVerified: true. The shadow row updates, and any local user-profile cache (Redis) must be invalidated.

To trace a single action across Auther and your app, pass x-request-id end-to-end and include user_id on every log line. Webhook events carry their own IDs — log them on receipt. Dashboards can then join on a (user_id, 5-minute window) key.

These bite when access must end now but a stateless token says otherwise.

The default foreign-key CASCADE removes notifications and audit logs along with the user. For audit compliance, break the audit FK, keep the rows, and scrub PII on delete. For notifications, cascade is usually fine.

Alice is deleted at t=0, but her JWT stays valid until t=900s — without mitigation she keeps access for fifteen minutes. Pick a recipe to match the risk:

  • Short TTL — 5 min for admin or sensitive routes, 15–60 min for normal traffic. Trades more refreshes for faster revocation.

  • jti deny-listjti is the JWT’s unique token ID claim. On user.deleted, push her session jtis to Redis with TTL = remaining JWT lifetime. The middleware checks if (await redis.exists('denylist:' + claims.jti)) return 401. A small latency cost buys real revocation.

  • Tombstone table — a “tombstone” is a small record that marks an ID as deleted so later code knows not to recreate it. Record the deletion and refuse to re-provision from a stale JWT:

    tombstone.ts
    await db.insert(deletedUsers).values({ id: userId, deletedAt: new Date() });
    // In ensureShadowUser:
    const tomb = await db.query.deletedUsers.findFirst({ where: and(eq(deletedUsers.id, sub), gt(deletedUsers.deletedAt, sub24h)) });
    if (tomb) throw new Error("user recently deleted");

    This prevents a zombie row being re-created from a token issued before the delete.

  • Fail-closed on sensitive ops — for money-moving actions, synchronously call GET ${AUTHER_URL}/api/admin/users/:id; on a 404, reject.

Most apps are fine with a short TTL. Banking-grade flows want short TTL plus a deny-list plus fail-closed on high-risk routes.

An admin revokes Alice’s users:admin in Auther, but her in-flight JWT still claims it — up to fifteen minutes of over-permission. The options mirror deletion: a short TTL, a permission-revision counter (a custom permissions_rev claim set via an Auther pipeline), or a forced /check-permission call for sensitive operations.

A JWT is stateless, so you cannot “invalidate” one without a deny-list. On sign-out, clear client-side storage (drop the BFF cookie — the session cookie set by your Backend-for-Frontend web layer — drop the mobile tokens), redirect to Auther’s /api/auth/sign-out (which ends Auther’s session cookie, forcing a full re-auth at the next /authorize), revoke the refresh token via /revoke if one was issued, and rely on the short access-token TTL for the residual window.

These bite around the token lifecycle — signing keys, clocks, claims, and the OIDC surface.

Auther rotates signing keys every 30 days and keeps old keys for 60. A createRemoteJWKSet configured with cacheMaxAge: 12h handles the overlap. If Auther’s /jwks is unreachable, jose keeps the stale cache rather than rejecting every token — the correct fail-mode.

Container clocks drift, producing "exp" claim timestamp check failed or "iat" claim in the future. jose’s clockTolerance: 30 grants 30 seconds of slack.

An admin logs in “as” Alice. Auther’s admin plugin issues a token where sub = usr_alice while the session records impersonatedBy = usr_admin, and a pipeline token_build node can inject that as an impersonator claim. Your audit logger must honor it:

apps/server/src/modules/audit-logs/service.ts
async function log(principal: Principal, action: string, subject: string) {
await db.insert(auditLogs).values({
actorId: principal.userId, // Alice
impersonatorId: principal.impersonator ?? null, // admin (if set)
action, subject, createdAt: new Date(),
});
}

A user about to transfer money should be re-challenged — that mid-session re-prompt for stronger proof is step-up auth. Redirect to Auther with prompt=login&acr_values=mfa; Auther forces 2FA and returns a fresh token carrying acr: "mfa" (acr is the authentication-strength claim) and a new auth_time. The route checks both the method and its recency:

function requireRecentMfa(maxAgeSec: number) {
return async (c: Context<Env>, next: Next) => {
const session = c.get("session");
const claims = /* extracted */;
if (claims?.acr !== "mfa" || (Date.now() / 1000 - claims.auth_time) > maxAgeSec) {
return c.json({ error: "mfa_required", redirectTo: "/step-up" }, 403);
}
return next();
};
}

Three decisions cover the offline matrix: a cached access token that hasn’t expired is used as-is; a cached-but-expired token with network available triggers a silent refresh via the refresh token; with no network at all, show “reconnect to continue” rather than pretending the user is authenticated.

Auther exposes /.well-known/openid-configuration via better-auth. Mobile SDKs (AppAuth) and server libraries use it to auto-configure their endpoints. Point them at ${AUTHER_URL}/api/auth/.well-known/openid-configuration (confirm the exact path against Auther’s current better-auth config).

Don’t break old payload shapes. Add fields, never remove them. If a removal is truly unavoidable, version the event type instead — user.updated.v2.

Rotate every shared secret with an overlap window so nothing breaks mid-flight:

  • Webhook signing secret — add the new secret to Auther, have your receiver accept both for a window, then remove the old one.
  • Client secret — rotate in Auther’s admin UI, then redeploy Hono with the new value.
  • Admin API key — create the new key, migrate the cron jobs onto it, then revoke the old one.

Need department, tenant, or a feature-flag bucket in the token? Write an Auther pipeline token_build node:

-- Auther pipeline: token_build node
local user = context.user
return {
custom_claims = {
department = user.department, -- if stored in Auther user profile
tenant = context.outputs.active_tenant or nil,
}
}

Keep claims small — every request carries them as header bytes over the wire.

These bite once, on day one, when you move an existing user base into Auther.

Bulk-import your existing Hono users by calling POST ${AUTHER_URL}/api/auth/sign-up/email with an x-internal-signup-secret, one user at a time (or batched via Auther’s admin bulk endpoint if it’s exposed). Preserve IDs if Auther’s signup or admin endpoints accept id — the Hono template already uses usr_* CUIDs, which Auther can adopt. For passwords:

  • Exact-hash import — if Auther’s better-auth config matches Hono’s argon2id params, dump accounts.password and re-insert it into Auther’s account table. This must go through Auther’s DB, not its API.
  • Force reset (recommended for most) — bulk-invite all existing users with a password-reset link via Auther.
  • Dual-read transition — accept both the old local auth and the new Auther auth for a window; users migrate naturally as they log in.

Pre-migration verified users should be imported with emailVerified: true in Auther. Don’t re-send verification emails to thousands of already-verified accounts.

These bite in production operations — tenancy, rate limits, CORS, logging, and the legal surface.

A request needs to know which org it’s acting in, and your instances keep no shared session state. On such stateless infrastructure, path-based tenancy (/api/orgs/:slug/...) beats session-based tenancy: it carries no server-side org state and survives across instances.

A job needs to call your API but has no logged-in human behind it. Give each job its own API key with scoped tuples, and cache the exchange result for about 14 minutes.

Auther owns sign-in rate limiting through its better-auth config, so remove the rateLimit and loginSecurityPlugin configuration from apps/server/src/modules/auth/instance.ts (which is going away regardless). Your app-endpoint rate limits in apps/server/src/middlewares/rate-limit.ts stay — they’re unrelated to auth.

Both projects ship a docker-compose. Run Auther’s stack on its default ports and Hono’s on different ports, and share a docker network so Hono can reach Auther without host.docker.internal tricks:

docker-compose.dev.yaml
# Hono project, fragment
services:
auther:
image: ghcr.io/you/auther:local
ports: ["4000:3000"]
environment:
BETTER_AUTH_DATABASE_URL: http://auther-db:8080
# ...
networks: [app]
auther-db:
image: ghcr.io/tursodatabase/libsql-server:latest
networks: [app]
networks:
app: {}

Seed test users in Auther (pnpm user:create in its scripts) and use them from Hono in dev.

CORS only enters the picture on direct browser-to-Auther fetches. A browser redirect to Auther is navigation, not CORS; the callback returns to your web origin; a BFF’s token exchange is server-side. Only a SPA fetching Auther directly triggers CORS — add the SPA origin to Auther’s CORS_ORIGIN. Your Hono server’s CORS origins are just the origins that call it (the web BFF, mobile); Auther isn’t one of them.

Either redirect admins to Auther’s /admin UI (simplest), or proxy through your Hono app’s admin UI using autherAdmin.

Sanitize Authorization headers, full emails, and IPs. For OpenTelemetry export, add a redaction processor — Auther’s metrics service already sanitizes tags, so follow suit in your Hono OpenTelemetry config (apps/server/src/middlewares/otel.ts).

Erasure flows as a chain: Auther delete → webhook → Hono cascade → internal event → downstream services. Document the chain for auditors, and state the backup policy explicitly: removed from live within X days, from backups within Y per retention.

Set skipConsent: true for B2B or trusted-client setups. Turn on per-client consent for B2C GDPR flows.