White-label authentication & consent UI for an Authlete-backed OAuth/OIDC Authorization Server (e.g. authlete/typescript-oauth-server). The AS is headless; auth-ui renders every screen the user sees during authentication and consent.
Built on Next.js 16 · Tailwind 4 · Better Auth · libSQL.
Standalone auth app — sign-in/up, 2FA, account settings. No AS, no cloud services, no manual steps (libSQL is just a file in dev):
npm install
npm run setup # .env + generated secrets + local SQLite schema
npm run dev # → http://localhost:3001As the OAuth consent UI — pair with a running typescript-oauth-server (default http://localhost:3000, with its AUTH_UI_URL pointing here):
AS_ISSUER_IDin.envis the AS's origin — the default already matches a local AS.AUTH_UI_JWKSis the signing key the AS verifies —npm run setupalready generated it.- End-to-end check (set
AS_BASE_URLto the AS origin for the script):
node --env-file=.env scripts/smoke-e2e.mjsThe two servers talk server-to-server (per-request mutual JWT), so they must be network-reachable to each other. The protocol spec is INTERACTION_PROTOCOL.md in the AS repo.
.env is grouped by mode (see .env.example). npm run setup fills the two generated secrets — you rarely touch these by hand.
| Variable | Purpose |
|---|---|
BETTER_AUTH_SECRET |
session secret — generated by npm run setup (or npm run keygen) |
BETTER_AUTH_URL |
this app's URL (default http://localhost:3001) |
DATABASE_URL |
libSQL target — a file locally, a Turso URL when hosted |
AS_ISSUER_ID |
(consent mode) the AS's stable identity — and the origin its public JWKS is fetched from to verify its JWTs |
AUTH_UI_JWKS |
(consent mode) this app's ES256 private JWKS — generated by npm run setup; public half published at /.well-known/jwks.json |
The runtime learns the AS's per-request callback URL from the interaction token, not from config. AS_BASE_URL is read only by the smoke-e2e.mjs check, to tell it which AS origin to drive.
Optional features
- Email — with no
RESEND_API_KEY, auth emails (verify, password reset) are logged to the console, so local dev needs no provider. Set the key to send for real;EMAIL_FROMneeds a verified domain in prod. - Social / OIDC sign-in — a provider's button appears only when both
<PROVIDER>_CLIENT_IDand<PROVIDER>_CLIENT_SECRETare set (works for any Better Auth built-in). Register the callback URL{BETTER_AUTH_URL}/api/auth/callback/<provider>with the provider.
Vercel-ready; Next.js is auto-detected (no vercel.json). The only requirement is a network database — serverless has no persistent local disk — so use a free Turso libSQL database. Same code path as local, just a remote target.
Turso (same steps as local, remote target):
- create a Turso account (free) and a database
- put its URL + token in
.envasDATABASE_URL(libsql://…) andDATABASE_AUTH_TOKEN npm run migrateto populate the schema
Vercel env vars: DATABASE_URL, DATABASE_AUTH_TOKEN, BETTER_AUTH_SECRET, BETTER_AUTH_URL (your deploy URL), and — for consent mode — AS_ISSUER_ID, AUTH_UI_JWKS. Mark the secrets Sensitive. Optional feature keys (email, social) live in .env.example.
The interaction protocol is server-to-server, so a hosted auth-ui needs an AS it can reach over the network (and the AS must reach it back) — a hosted auth-ui can't pair with a localhost AS.
Ships unbranded. Rebrand from one file:
src/brand/brand.tsis the single source of truth — product name, logo, font, colors, sign-in panel copy. Colors flow into CSS variables; nothing else hardcodes a brand value.- Set
logoMarkto an image in/public/brand, or keep the built-in neutral mark. - Consent wording — scope and permission labels — lives in
src/lib/consent-labels.ts, one file to localize or relabel. - Or replace the UI entirely — anything that speaks the same protocol to the AS works.
Most files in this repo are managed — installed from the better-auth-ui registry (built on shadcn/ui primitives). The code that is actually this project is a thin layer around them:
| Path | Status |
|---|---|
src/components/auth/, src/components/ui/ |
Managed — registry-installed; don't hand-edit, update by re-installing |
src/lib/auth/ |
Mixed — registry plugins + a few owned hooks |
src/brand/ |
Owned — the white-label surface (see below) |
everything else in src/ |
Owned — Better Auth config, email transport, and the AS interaction protocol (lib/jws, lib/jwks, lib/as-client, app/authorizations, app/apps, app/api/users) |
Update managed code by re-installing, never by editing:
npx shadcn@latest add @better-auth-ui/<name> # better-auth-ui: auth, two-factor, …
npx shadcn@latest add <name> # shadcn/ui primitives: button, dialog, …New features (2FA, passkeys, social, …) come from the registry: install the component, wire it in config, never fork it.
Decouples authentication and consent from the AS. The AS stays a thin, spec-compliant OAuth/OIDC surface holding no per-transaction state; auth-ui owns everything the user touches.
| Component | Role |
|---|---|
| RP | the app requesting access: starts /authorize, receives tokens. Integrates with the AS using standard OAuth/OIDC. |
| AS | OAuth/OIDC endpoints; delegates authentication/consent to auth-ui; owns the redirect back to the RP |
| auth-ui | the UI the user actually sees: authenticates the user, collects consent, records the decision against an opaque authorization id |
| Authlete | protocol engine; owns per-transaction state; only the AS calls it |
auth-ui holds the user session (Better Auth), not the OAuth transaction. It speaks a small component protocol to the AS, authenticated by per-request mutual JWT — each side publishes a JWKS and verifies the other:
GET /api/authorizations/{id}— fetch the in-flight authorization (auth-ui → AS)POST /api/authorizations/{id}/outcome— report an interaction outcome: authenticate, then consent (auth-ui → AS)GET /api/users/{id}— resolve user claims (AS → auth-ui)GET /.well-known/jwks.json— auth-ui's public keys (AS → auth-ui)
Beyond the auth/consent transaction, auth-ui also renders a connected-apps screen (/apps) to review and revoke granted access, over an AS management API (same mutual JWT):
GET /api/authorized-apps— list a user's granted apps (auth-ui → AS)DELETE /api/authorized-apps/{clientId}— revoke an app's access (auth-ui → AS)
Why: the AS stays implementation-portable (Node service, sidecar, gateway, edge worker), while authentication (MFA, passkeys, federation) and consent (per-claim, RAR, grant management) evolve entirely in auth-ui — none of which the AS ever sees.
Supported today:
- From the registry: email/password, email verification, password reset, multi-account device sessions, TOTP 2FA + backup codes, env-gated social / OIDC sign-in (Google, Microsoft, …).
- Consent: RAR consent capture (renders
authorization_details) and a connected-apps panel (/apps) to review and revoke granted access.
Planned:
- Passkeys (WebAuthn) · Magic link · custom-issuer OIDC (Okta/Auth0 via discovery)
- Richer consent — Persistent grant lifecyle management (RAR consent capture shipped)
- Account recovery / step-up
Apache-2.0