Skip to content

Auth Server — Reference

apps/auth-server runs Better Auth on Fastify (port 3673). It's the identity layer for the platform: email/password sign-up, JWT issuance with JWKS for downstream verification, and SIWE wallet linking. Tables live in a dedicated better_auth Postgres schema.

Three-Layer Identity Model

public.users         ← canonical UUID — every downstream FK references this
   ↓ 1:1
public.user_auth     ← bridge: { user_id, auth_provider_user_id, auth_provider }

better_auth.user     ← Better Auth owned, in dedicated Postgres schema
better_auth.session
better_auth.account
better_auth.verification
better_auth.jwks
better_auth.walletAddress

The JWT sub claim is our users.id UUID, not Better Auth's text id. This means downstream services (game-server, evm-bank, poker app) never need to know what an "account row" looks like in Better Auth's schema — they get an opaque user id and use it as a foreign key.

Swapping auth providers later means rewriting user_auth rows; downstream queries never change.

Privy Auth Bridge

Privy is the auth provider (headless — the brat-themed login UI is ours; Privy runs the OAuth). Login is invite-code-gated social sign-in. The bridge slots Privy into the three-layer model: it verifies a Privy session server-side, maps the Privy user id (sub) to our users.id through user_auth, and mints our existing JWKS-signed JWT — so game-server / evm-bank verification is unchanged.

The mint reuses Better Auth's server-only signJWT (it signs an arbitrary payload with the same JWKS keypair /api/auth/jwks publishes — there is no public /api/auth/sign-jwt route, so no arbitrary-mint surface). No Better Auth session is involved.

Failure branches (each emits an auth.privy.bridge span with error.code): privy_token_missing (400), privy_token_invalid (401), invite_required (403, first sign-in with no code), invite_invalid (403, code expired / exhausted / unknown). A refused invite creates no account — the consume and the user-create share one transaction.

The Privy bridge is the ONLY public account-creation path

Registration is invite-gated, so every other way to create an account is closed at the network edge. A single onRequest chokepoint returns 403 account_creation_disabled for:

  • /api/auth/sign-up* — email/password signup (all path variants, e.g. a trailing slash)
  • /api/auth/siwe/verify — Better Auth's SIWE anonymous sign-in creates a user, bypassing the invite gate
  • /api/auth/sign-in/social + /api/auth/callback* — Better Auth's own OAuth (its socialProviders are also removed; Privy owns OAuth now)

What stays open (these don't create accounts): /api/auth/sign-in/email (admin login — one auth-server serves the poker-Privy and admin-BetterAuth apps; a poker user can never obtain a Better Auth account to sign into), /api/auth/siwe/nonce (the custom wallet-link route uses it), /api/auth/token, /jwks, /get-session. A blocked request logs at WARN (auth.blocked_path) so bypass probing is visible.

The test-token harness is fail-CLOSED

In dev/test, the bridge accepts a test-privy-token:<sub> value (and POST /api/dev/mint-jwt exists) so the auth flow is CI-testable without real OAuth. This is total identity forgery if it ever activates in production, so it fails closed: it is permitted ONLY when ENABLE_TEST_AUTH=true and NODE_ENV is an explicit dev value (development or test). Unset / unknown / production → off. Boot refuses to start if ENABLE_TEST_AUTH=true with a non-dev NODE_ENV (fail-loud).

Operator note: every environment must set NODE_ENVtest for the test profile, development for local, production for prod (or simply never set ENABLE_TEST_AUTH in prod). The harness is safe-by-default when ENABLE_TEST_AUTH is unset.

Public email/password signup is disabledPOST /api/auth/sign-up/email returns 403. (The in-process auth.api.signUpEmail used by the test harness is unaffected.) The password-recovery flow is retired alongside it.

Embedded wallets + fiat on-ramp are deferred to a post-Solana, Solana-native phase. This first cut is social login + invite-code registration only.

JWT Claims Schema

typescript
interface NumeroJwtClaims {
  sub: string;          // public.users.id (UUID)
  email: string;        // primary email from Better Auth
  walletAddress: string | null;  // primary linked wallet (EIP-55 checksum), or null
  authMethod: string;   // 'email' | 'siwe' | 'google' | 'apple'
  iat: number;
  exp: number;
  iss: string;          // baseURL of the issuing auth-server
  aud: string;          // baseURL of the issuing auth-server
}

Algorithm: EdDSA / Ed25519. Verifiable with jose via the JWKS endpoint.

sub override: the JWT plugin's definePayload cannot override sub — Better Auth always sets it from getSubject (defaults to the Better Auth user.id). To put our users.id in sub, the plugin is configured with a custom getSubject that joins through user_auth (apps/auth-server/src/auth.ts).

Endpoints

Health

GET /health
→ { ok: true, latency: 12 }

DB ping. Returns { ok: false, error } if Postgres is unreachable.

Better Auth Routes

All Better Auth routes are mounted under /api/auth/*. Documented at better-auth.com. Notable ones:

MethodPathPurpose
POST/api/auth/sign-up/emailEmail/password signup. Triggers databaseHooks.user.create.after which inserts public.users + public.user_auth atomically.
POST/api/auth/sign-in/emailEmail/password sign-in. Returns session token + sets session cookie.
POST/api/auth/sign-outEnd session.
GET/api/auth/get-sessionReturns the current session if any.
GET/api/auth/tokenIssues a fresh JWT for the current session.
GET/api/auth/jwksJWKS endpoint — public keys for downstream verification.
POST/api/auth/siwe/nonceIssues a nonce for a wallet address + chain id. Used by /api/link-wallet (below).

Avoid /api/auth/siwe/verify. It is "sign in with a wallet," not "link a wallet to my account." It always replaces the current session with the wallet's user. For linking, use POST /api/link-wallet instead.

POST /api/link-wallet

Custom route. Links a wallet to the currently signed-in user's account.

POST /api/link-wallet
Cookie: better-auth.session_token=...    (or Authorization: Bearer <token>)
Origin: <one of trustedOrigins>
Content-Type: application/json

{
  "message": "<SIWE-formatted message>",
  "signature": "0x...",
  "walletAddress": "0xABC...",
  "chainId": 84532
}

Flow:

  1. Reads the session via auth.api.getSession({ headers }). 401 if not signed in.
  2. Validates body. Checksums address with viem getAddress.
  3. Looks up the nonce from better_auth.verification (identifier siwe:{checksum}:{chainId}). Must have been issued by /api/auth/siwe/nonce. 401 if missing or expired.
  4. Asserts the signed message contains the issued nonce (replay protection).
  5. Verifies the signature with viem verifyMessage.
  6. Sybil check: 409 if the wallet exists with a different userId.
  7. Idempotent: if the wallet exists with the current user, returns { ok: true, alreadyLinked: true }.
  8. First wallet for the user becomes isPrimary = true.
  9. Inserts into better_auth.walletAddress, deletes the verification row (one-time nonce).

Responses:

StatusBody
200{ ok: true, walletAddress, chainId, isPrimary }
200 (idempotent){ ok: true, alreadyLinked: true, walletAddress, chainId, isPrimary }
400{ error: 'invalid walletAddress' }
401{ error: 'not signed in' } / { error: 'no nonce' } / { error: 'invalid signature' } / { error: 'message does not contain issued nonce' }
409{ error: 'wallet already linked to another account' }

GET /api/wallets

List the current user's wallets.

GET /api/wallets
→ { wallets: [{ address, chainId, isPrimary, createdAt }, ...] }

Session-scoped — users only see their own wallets. Empty array if not linked.

POST /api/wallets/set-primary

Atomically swap which wallet is primary.

POST /api/wallets/set-primary
{ "walletAddress": "0xABC..." }

Behavior:

  • 404 if the wallet doesn't belong to the current user (no information leak about whether the wallet exists at all).
  • 200 + { ok: true, primary, alreadyPrimary: true } if it's already primary.
  • Otherwise: in a single transaction, sets all of the user's wallets to isPrimary = false and the target wallet to isPrimary = true. The system never observes a state with zero or two primaries.

After this call, the next JWT issued will carry the new primary in walletAddress.

DELETE /api/wallets/:address

Unlink a wallet.

DELETE /api/wallets/0xABC...

Behavior:

  • 404 if the wallet doesn't belong to the current user.
  • Deletes the row.
  • If the deleted wallet was primary AND the user has other wallets, the most-recently-created remaining wallet is auto-promoted to primary in the same transaction.
  • Unlinking the user's only wallet is allowed. Subsequent JWTs will return walletAddress: null.

Response: { ok: true, unlinked, wasPrimary }.

Solana wallet linkage (chain discriminator)

better_auth.walletAddress carries a chain column (evm | solana, default evm; migration 0069). It lets one table hold both EVM addresses and base58 Solana pubkeys with chain-isolated linkage reads:

EndpointReturns
GET /api/internal/user-wallets/:publicUserIdEVM wallets only (WHERE chain='evm')
GET /api/internal/user-wallets-solana/:publicUserIdSolana wallets only (WHERE chain='solana') → { wallets: [{ address }] }

Both are service-key gated (an x-service-key header). The Solana endpoint is consumed by the Solana cage's lib/linkage.ts (isSolanaWalletLinked) before crediting a deposit or paying a withdraw/redeem — the same trust model as the EVM linkage read.

Why the split, not one endpoint: EVM addresses are case-insensitive, but base58 Solana pubkeys are case-sensitive. The walletAddress_address_lower_uq unique index (lower(address)) is therefore partial — WHERE chain='evm'; lowercasing a base58 pubkey could false-merge two distinct wallets. Solana rows store the pubkey verbatim, chainId=0 (sentinel — Solana has no numeric chain id), and rely on the column-level case-sensitive address.unique().

The Solana link-write is linkSolanaWallet() (in routes/wallets.ts, span auth.wallet.solana_linked), called by the SIWS-verified link flow. There is no service-key internal link endpoint — that would let any backend assert linkage without ownership proof, a fund-safety hole; linking always requires a wallet signature.

POST /admin/invite-codes

Mint a registration invite code. Service-key auth (an x-service-key header). Not user-facing; the full operator dashboard is Privy's hosted console. This is the minimal seam to seed codes.

POST /admin/invite-codes
x-service-key: <service-key>
{ "maxUses": 1, "expiresInSeconds": 604800, "createdBy": "ops" }

Body (all optional):

  • maxUses — total consumptions allowed. Default 1 (single-use).
  • expiresInSeconds — seconds until expiry; omit for "never expires". A negative value mints an already-expired code (a test affordance; otherwise harmless — an expired code only ever refuses).
  • createdBy — free-form minter identifier for audit. Default "admin".

Behavior:

  • 401 if x-service-key is missing/wrong; 503 if no service key is configured.
  • 201 with { code, maxUses, expiresAt, createdAt } on success.
  • Emits one auth.invite.mint span (otel.category=auth; error.code=invite_mint_failed + ERROR status on the failure leg).

Mint a code with the mint-invite-code helper or directly:

bash
curl -sX POST "$AUTH_BASE_URL/admin/invite-codes" \
  -H "x-service-key: <service-key>" \
  -H 'content-type: application/json' \
  -d '{"maxUses":1}'

Registration gate

Registration is invite-code-gated. Codes live in the invite_codes table (a reused legacy faucet allowlist — no migration). At the first Privy sign-in for a new identity, the bridge calls consumeInviteCode(code) from @numero/db:

  • The consume is atomic — the cap (current_uses < max_uses) and expiry predicate live in the UPDATE's WHERE, so two concurrent consumes of a single-use code can never both succeed.
  • A refused consume (invalid / expired / exhausted) creates no account — the gate holds on the failure branch.
  • Returning users are recognized by their existing user_auth row and need no code.

Configuration

Env varRequiredNotes
AUTH_SECRETyesBetter Auth signing secret. Generate with openssl rand -base64 32.
AUTH_BASE_URLyesThe URL Better Auth uses as iss/aud and for cookie domain. Inside Docker this is the auth-server's internal service URL; on the host it's http://localhost:3673.
AUTH_DOMAINyesSIWE message domain. localhost in dev, skillspoker.com in prod.
DATABASE_URLyesPostgres URL. The auth-server reads/writes both public.* (users, user_auth) and better_auth.*.
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETnoIf both set, Google OAuth is enabled. Otherwise the provider is inert.
APPLE_CLIENT_ID / APPLE_CLIENT_SECRETnoSame gating as Google.
PORTnoDefaults to 3673.

Trusted origins. Better Auth's CSRF middleware rejects requests whose Origin header doesn't match baseURL. Auth-server is configured with a trustedOrigins list covering localhost:3673, the poker/dealer/admin app ports, and the production domains. Add new origins in apps/auth-server/src/auth.ts.

Verifying JWTs Downstream

Other services verify JWTs independently — they call JWKS once and cache it locally. No callback to auth-server per request.

typescript
import { createRemoteJWKSet, jwtVerify } from 'jose';

const jwks = createRemoteJWKSet(new URL(process.env.AUTH_JWKS_URL!));

export async function verifyJwt(token: string) {
  const { payload } = await jwtVerify(token, jwks);
  return {
    userId: payload.sub as string,           // public.users.id UUID
    email: payload.email as string,
    walletAddress: (payload.walletAddress as string | null) ?? null,
    authMethod: payload.authMethod as string,
  };
}

AUTH_JWKS_URL should resolve to the auth-server's /api/auth/jwks endpoint — the internal service URL inside Docker, http://localhost:3673/api/auth/jwks for host scripts, or https://auth.skillspoker.com/api/auth/jwks in production.

Password Recovery

Self-service reset.

Forgot password? → POST /api/auth/request-password-reset { email, redirectTo }
                 → email (Resend) or console link (local)
                 → {app}/reset-password?token=…
                 → POST /api/auth/reset-password { newPassword, token }

Transports (apps/auth-server/src/email/reset-password.ts):

EnvBehavior
Email API key setReal email via the transport provider's HTTP API (no-reply@skillspoker.com)
unset, non-productionReset URL logged with the [password-reset-link] marker
unset, productionBoot assert fails — no silent no-transport state

Rules baked in: unknown emails return the identical success response, and the response is the SAME for every email even during a transport outage (no account enumeration) — a transport failure is loud in telemetry (an ERROR auth.password_reset.requested span), never in the HTTP status (an HTTP-status loud failure was an enumeration oracle, since Better Auth only sends for known emails). The forgot flow passes an absolute redirectTo (window.location.origin/reset-password) so the emailed link redirects to the app origin, not the auth-server's. Expired and reused tokens are coded errors with a request-a-new-link path; a successful reset revokes every other session (revokeSessionsOnPasswordReset).

Token TTL: AUTH_RESET_TOKEN_TTL_SECONDS (default 3600; test profile 4 so the expiry leg stays fast). Spans: auth.password_reset.requested (reset.transport, error.code) + auth.password_reset.completed — the service's first telemetry contract, tripwire-enforced.

The "Forgot password?" affordance lives on every sign-in surface (admin + poker via the shared SignInCard, poker-next's DashboardAuth), and each app serves /reset-password OUTSIDE its auth gate.

Observability

Auth-server is fully wired into OTel via @numero/telemetry:

  • bootstrap.ts calls initTelemetry('auth-server') before any other module loads. ES module hoisting requires this — it must be a separate entry point that uses dynamic import('./index.js').
  • service.name = auth-server, service.namespace = auth-server, service.instance.id = <hostname>.
  • Visible in Dash0 service catalog under the dev dataset.
  • Pino HTTP request logs stream automatically.
  • link-wallet and wallet management routes log structured events: Wallet linked, Primary wallet changed, Wallet unlinked. Sybil rejections and signature failures log at warn level.

Local Development

Two ways to run auth-server:

Host process (fast iteration, what tests run against):

bash
cd apps/auth-server
DATABASE_URL=postgresql://postgres@localhost:5432/numero \
AUTH_BASE_URL=http://localhost:3673 \
AUTH_SECRET=$(openssl rand -base64 32) \
pnpm dev

Docker (matches production wiring, runs as part of the local stack):

bash
pnpm env:pull local   # once, or after a Doppler change
docker compose up -d

The container is named numero-auth-server-local-1, exposes port 3673, and uses the same better_auth schema as the host process. Don't run both at the same time — they'll fight for the port.

Test Suite

apps/auth-server/test-siwe.mjs is an end-to-end script that exercises the full auth + linking + wallet management flow. It signs SIWE messages with viem privateKeyToAccount and asserts JWT claims via jose. 24 assertions, all passing as of Phase 2:

SectionCases
A. Happy path linkwalletA linked, primary, JWT claim correct
B. Sybil rejectionuser2 cannot link walletA → 409, user2 JWT stays clean
C. Multi-walletwalletB linked non-primary, walletA stays primary
D. JWKS verifyfinal JWT verifies cryptographically with jose
E. Listendpoint returns array, user2 isolation
F. Set-primaryswap works, JWT updates, cross-user 404
G. Unlink primarywalletA auto-promotes, cross-user 404
H. Unlink last walletwalletAddress returns to null

Run against either host or Docker auth-server:

bash
cd apps/auth-server
AUTH_BASE_URL=http://localhost:3673 node test-siwe.mjs