Skip to content

End-to-End Flows

Schema, auth core, and server internals are all in place — now watch the system actually run. Each of the six traces below follows one real request end-to-end, from the browser (or cron) through Auther, the webhook handler, and the Hono server’s auth-context middleware, so the moving parts click into place. Where a flow surfaces a failure mode worth hardening against, it points to the Edge Cases Catalog.

What this proves: the webhook and the JIT shadow-row insert converge on the same users row no matter which arrives first — so a new account is never lost in the race between Auther and your server.

Web signup — confidential client + webhook fan-out
Rendering diagram…

The webhook body is { type: "user.created", payload: { id: "usr_xyz", email, ... } }, signed with the x-webhook-{id,signature,timestamp} headers. Once verified and de-duplicated, the handler inserts the shadow row keyed on the Auther user id.

What this proves: a mobile public client runs exactly the same PKCE handshake as an SPA — PKCE being the proof-key step that lets a secret-less client prove it started the flow — and because the shadow row already exists from signup, the first API call needs no fresh insert.

  1. The mobile app opens an ASWebAuthenticationSession (iOS) pointed at auth.example.com/api/auth/oauth2/authorize with client_id=hono-mobile, redirect_uri=com.myapp://auth/callback, response_type=code, a code_challenge, and scope=openid email profile offline_access.
  2. Auther shows the sign-in screen; Alice enters her password (plus 2FA if enabled).
  3. Auther redirects to com.myapp://auth/callback?code=....
  4. The system browser returns control to the app, which extracts the code.
  5. The app exchanges it: POST /api/auth/oauth2/token with grant_type=authorization_code, the code, the code_verifier, and client_id=hono-mobile — no client secret, because this is a public client.
  6. Auther returns access_token (1h), refresh_token (7d), and id_token (1h).
  7. The app stores the refresh_token in the OS Keychain and keeps the access_token in memory.
  8. The app calls the Hono API with Authorization: Bearer <access_token>. The middleware validates the token, finds Alice already in the shadow table (inserted during web signup), attaches her, and the request proceeds.
  9. When the access token expires, the app calls POST /token with grant_type=refresh_token to get a new access token — and a rotated refresh token, which it must store in place of the old one.

What this proves: adopting Auther changes only the source of a principal’s roles, not the request flow — authorization now reads JWT permissions instead of a local users.roleSlugs column.

Domain request — POST /api/notifications/send
Rendering diagram…

Structurally nothing about the request changed. The route still resolves a principal, runs authorize("notifications", "send"), validates the body with zod, and writes a notifications row whose foreign key to users.id still resolves. The only shift is that the principal’s roles now come from the JWT permissions claim rather than from users.roleSlugs. (jwtVerify vs JWKS in the trace means the middleware checks the token’s signature against Auther’s published signing keys — the JWKS endpoint — which it caches, so no per-request call to Auther.)

What this proves: revocation is inherently two-stage — Auther bans upstream, your webhook syncs locally, and Alice’s in-flight token only dies when it expires or you deny-list it — so you must choose a TTL-versus-deny-list policy deliberately.

Admin ban — POST /api/users/:userId/deactivate
Rendering diagram…

Alice’s in-flight JWT stays valid for its remaining TTL (roughly 5–60 minutes). To close that window you have two levers, used alone or together:

  • A short access-token TTL plus refusing refresh for banned users — Auther rejects the refresh, so the token simply expires.
  • A jti deny-list — jti is the unique token id baked into each JWT, so the admin endpoint puts Alice’s active sessions on a Redis block-list keyed by jti that the auth middleware checks and rejects on the next request.

What this proves: a hard delete upstream can let a still-valid token re-create the very row you just deleted — a “zombie user” claws its way back — unless you tombstone the id (mark it permanently dead so JIT refuses to re-insert it).

GDPR delete — autherAdmin.deleteUser('usr_alice')
Rendering diagram…

The delete webhook handler runs in a single transaction: it scrubs denormalized identity fields (for example UPDATE audit_logs SET actor_email = '<deleted>', since the foreign key was already broken when the row left users), deletes the users row, and lets the foreign-key cascade clean up notifications, push_tokens, notification_preferences, and members.

What this proves: a browserless service authenticates with the same Bearer-token machinery as a user — the API key’s JWT carries a service-user sub and tuple-granted permissions, so the digest route needs no special-casing.

  1. The cron service fires at 2am.

  2. The job calls getJobToken(): if the cache is warm it reuses the token, otherwise it exchanges AUTHER_API_KEY for a JWT (valid 15 minutes).

  3. For each user to notify, the job calls the digest endpoint with the job token:

    jobs/digest.ts
    await fetch(`${HONO_URL}/api/notifications/digest`, {
    method: "POST",
    headers: { Authorization: `Bearer ${jobToken}` },
    body: JSON.stringify({ userId, payload }),
    });
  4. The Hono server treats it like any other request: the JWT validates (its sub is the service user associated with the API key), JIT may create a shadow row for that service user the very first time the cron ever runs, and authorize("notifications", "send") passes because the API key holds the tuple (notifications, *, editor, apikey, <id>).

  5. The notification is delivered exactly as in the domain-request flow above.