The Shadow-User Pattern
When you adopt a centralized identity provider, your app no longer owns the user
record — Auther does. But most applications still have a users table that other
tables point at with foreign keys, that joins rely on, and that cascade deletes walk.
The shadow-user pattern reconciles those two facts: it keeps a local mirror of
Auther’s user, populated and kept current automatically, so every existing FK and
INNER JOIN users keeps working while identity itself lives in Auther.
This page is the mechanism in the abstract — framework-agnostic. The next chapter, Hono Walkthrough — Schema, applies it to a concrete Drizzle/Hono codebase file by file.
The four moving parts
Section titled “The four moving parts”A shadow-user integration is four cooperating pieces. Keep the responsibilities separate — that separation is what keeps the request hot path fast and the data eventually consistent.
- Auth middleware — verifies JWTs on every request and attaches the user principal to the request context.
- JIT provisioner — on the first request for a user not yet in the shadow table, inserts a shadow row from the JWT claims.
- Webhook receiver — listens for Auther’s lifecycle events, keeps the shadow row current, and cascades deletes.
- Reconciliation job (optional) — a nightly safety net for any webhooks that were missed.
Identity flows in exactly one direction. Auther is the source of truth; webhooks (and the just-in-time path) push changes down into the shadow copy; everything downstream hangs off the shadow row’s primary key by foreign key. Nothing ever writes identity back up to Auther.
flowchart LR subgraph Auther["Auther (source of truth)"] AU["users<br/>id · email · name"] end subgraph App["Your app (e.g. a Hono server)"] SU["users (shadow)<br/>id = Auther sub<br/>email, name (cached)<br/>+ onboardingDone"] DOWN["notifications · push_tokens<br/>audit_logs · members · ..."] end AU -->|"webhook sync + JIT (one-way)"| SU SU -->|"foreign key"| DOWN
ID strategy: use Auther’s sub verbatim
Section titled “ID strategy: use Auther’s sub verbatim”Three reasons make this worth committing to:
- Joins stay trivial. A column like
notifications.user_idpoints straight at the shadow row and matchesjwt.subin a single step — no indirection. - There is no dual-write problem. If Auther and your app could ever disagree
about a user’s ID, that disagreement would be a bug. Using
subdirectly makes the disagreement impossible to express. - Bulk operations stay simple.
DELETE FROM users WHERE id = ?keyed onsubis the exact same operation as handling auser.deletedwebhook.
JIT provisioning
Section titled “JIT provisioning”The first time your app sees a valid JWT for a user who isn’t in the shadow table yet, it inserts them. This is the just-in-time path: it makes a brand-new user serviceable on their very first request, before any webhook could have arrived.
The claims you read off the JWT have a fixed shape, and the provisioner is a single insert-or-fetch:
import { eq } from "drizzle-orm";import { db } from "@/db";import { users } from "@repo/db/schema";
export interface AutherClaims { sub: string; iss: string; aud: string | string[]; exp: number; iat: number; email?: string; email_verified?: boolean; name?: string; picture?: string; permissions?: Record<string, string[]>; abac_required?: Record<string, string[]>; sid?: string;}
export async function ensureShadowUser(claims: AutherClaims) { const existing = await db.query.users.findFirst({ where: eq(users.id, claims.sub), }); if (existing) return existing;
const [inserted] = await db .insert(users) .values({ id: claims.sub, // ← Auther's ID email: claims.email ?? `${claims.sub}@unknown.local`, name: claims.name ?? claims.email ?? claims.sub, emailVerified: claims.email_verified ?? false, image: claims.picture ?? null, }) .onConflictDoNothing() .returning();
return inserted ?? (await db.query.users.findFirst({ where: eq(users.id, claims.sub), }))!;}Three details in that function carry the design:
onConflictDoNothing()handles the race where two concurrent first-requests both try to insert the same user. One insert wins; the other falls through to the re-select at the bottom.- Fallback values keep a request serviceable even if the JWT is missing a claim
— note the
${claims.sub}@unknown.localsynthetic email. The next webhook corrects the row. - Insert-only keeps the hot path fast. The JIT path never updates an existing row; updates belong to webhooks.
The webhook receiver: three invariants, in order
Section titled “The webhook receiver: three invariants, in order”The JIT path creates the row; webhooks keep it current and cascade deletes. Auther
delivers user.created, user.updated, and user.deleted events to a receiver
endpoint in your app. That endpoint is exposed to the internet, so it must validate
every payload before trusting it — and the validation steps have to run in a strict
order.
- Signature — verify the HMAC-SHA256 signature with a constant-time compare. Never parse JSON out of an unverified payload.
- Timestamp — reject anything more than five minutes old. This blocks replay of a captured payload.
- Idempotency —
SETNXthex-webhook-idin Redis. Auther’s QStash retries on any 5xx you return, so deduping is what prevents a retried event from being applied twice.
Reconciliation
Section titled “Reconciliation”Webhooks drop occasionally — during deploys, on transient database errors, or when the idempotency cache is wiped. A nightly reconciliation job (or a manual “resync” button) pulls the current state from Auther and fixes any silent drift. It’s the optional fourth moving part: cheap insurance against the rare missed event rather than something the request path depends on.
Permission sync: read-at-request vs. webhook-sync
Section titled “Permission sync: read-at-request vs. webhook-sync”Authentication tells you who the user is; authorization tells you what they can do. There are two ways to get permissions into your app, and they aren’t mutually exclusive.
- Read from the JWT at request time (recommended). The token already carries a
permissionsclaim. Your middleware attaches it, and routes read from it. No local permissions table is needed. - Webhook-sync into a local table such as
auth_relations. This is useful when you need admin-style queries like “list every user withadminon org X” that a per-request JWT can’t answer. Do this in addition to reading from the JWT, not instead of it.
Most apps start with read-at-request and add the local table only when the first admin feature actually needs it.
This is the pattern in the abstract. With the four parts, the ID rule, and the webhook invariants in hand, the next chapter grounds all of it in a real codebase — see Hono Walkthrough — Schema for the file-by-file migration.