Hands-on

Authentication & Admin Architecture

Deep dive into how this template handles Better Auth sessions, OAuth, RBAC, and business data modeling for credits and quota.

Authentication & Admin

This document is the source of truth for how authentication and admin authorization work in this template.

It covers:

  • The exact Better Auth model used here
  • How email/password and Google OAuth flows persist sessions
  • Why users, accounts, sessions, and verifications are separate
  • What is auth data vs what is business data (credits, quotas, permits)
  • How admin RBAC is implemented in this codebase

1) Authentication Model Used in This Template

This project uses cookie-based, server-managed sessions backed by Postgres. It is not JWT-first auth by default.

Why:

  • Better Auth is configured with Drizzle + Postgres adapter in src/lib/auth.ts
  • Session storage maps to the sessions table in src/db/schema.ts
  • nextCookies() plugin is enabled
  • No JWT plugin is enabled for primary auth

Practical implication:

  1. Browser stores an HttpOnly auth cookie (session token)
  2. Server validates session token against sessions
  3. Session row points to the user identity

JWT support exists in Better Auth, but it is opt-in and does not replace the core DB session model used here.


2) Core Auth Tables and Responsibilities

Better Auth intentionally splits identity, credentials, and login state.

users (identity profile)

Represents who the person is in your product.

Typical fields:

  • Email
  • Display profile (nickname, avatar_url)
  • Role (role)
  • Verification/status flags
  • Created/updated timestamps

In this template, additional mapped fields include:

  • uuid (generated on create)
  • role

This table should stay relatively stable.

accounts (authentication methods)

Represents how they can sign in.

  • One user can have multiple accounts/providers
  • Stores provider identity and provider tokens

Typical fields:

  • provider_id
  • account_id
  • Password hash (email/password)
  • OAuth access token / refresh token
  • OAuth token expiration timestamps

This table is credential-focused and security-sensitive.

sessions (login state)

Represents active login sessions for your app.

Typical fields:

  • user_id
  • token
  • expires_at
  • ip_address
  • user_agent

Properties:

  • Session expiration controls whether user stays logged in
  • Revoking sessions is done by deleting/invalidating rows
  • Sliding refresh behavior is controlled by session policy

verifications (one-time flows)

Used for temporary tokens in flows such as:

  • Password reset
  • Email verification
  • Magic link / OTP (if enabled)

Normal sign-in/sign-out does not continuously write here, so seeing very few rows is expected.


3) End-to-End Auth Procedure in This Codebase

3.1 Email + Password Sign-Up

  1. Client submits sign-up form
  2. Better Auth creates a users row
  3. databaseHooks.user.create.before in src/lib/auth.ts ensures uuid exists
  4. Better Auth creates an accounts row for email/password
  5. Better Auth creates a sessions row
  6. Session cookie is set in response
  7. databaseHooks.user.create.after triggers welcome email async

Result: user is signed in immediately.

3.2 Email + Password Sign-In

  1. Client submits credentials
  2. Better Auth validates password hash in accounts
  3. Better Auth creates/refreshes session in sessions
  4. Session cookie is set/updated

3.3 Google OAuth Sign-In

  1. User clicks Google sign-in
  2. OAuth redirect + callback flow executes
  3. Google returns account identity (sub) and profile claims
  4. Better Auth finds or creates user
  5. Better Auth creates/updates accounts row for provider google
  6. Better Auth creates session row
  7. Session cookie is set

4) Important OAuth Identity Rule

Google sign-in does not require a Gmail address.

  • Users may use Gmail, Outlook, Yahoo, or custom domain email tied to a Google account
  • Identity should be treated as provider account identity (provider_id, account_id / sub), not email domain

Never treat email domain as proof of provider identity.


5) Session Expiration vs OAuth Token Expiration

These are separate lifecycles.

App session lifecycle (sessions)

  • Controls login state in your app
  • If session expires, getSession() returns null
  • User must sign in again

Provider token lifecycle (accounts)

  • Controls your backend's ability to call provider APIs
  • Access tokens expire quickly
  • Refresh tokens may be used to get new access tokens

Critical point:

  • Better Auth session expiry does not auto-log users back in via Google refresh token
  • Refresh token usage is for provider API calls, not app authentication

6) Why Sessions and OAuth Tokens Must Stay Separate

This separation is required for predictable security:

  • OAuth refresh token is not app identity proof
  • Session revocation must remain meaningful
  • Explicit re-authentication should be possible
  • Security/audit boundaries stay clear

Auto-recreating app sessions from provider refresh tokens would weaken revocation and auditability.


7) Authorization (RBAC) in This Template

Authentication answers "who are you". Authorization answers "what can you do".

Roles are DB-driven in users.role:

  • user
  • admin_ro
  • admin_rw

Role checks are implemented in src/lib/authz.ts:

  • requireAdminRead() -> allows admin_ro and admin_rw
  • requireAdminWrite() -> allows admin_rw

Admin layout guard: src/app/(admin)/layout.tsx.


8) Current Admin Surfaces and APIs

Admin pages

  • Dashboard: src/app/(admin)/admin/page.tsx
  • Users list: src/app/(admin)/admin/users/page.tsx
  • User detail/quota/credits: src/app/(admin)/admin/users/[uuid]/page.tsx
  • Orders list/status view: src/app/(admin)/admin/orders/page.tsx
  • Feedbacks: src/app/(admin)/admin/feedbacks/page.tsx
  • Reservations: src/app/(admin)/admin/reservations/page.tsx
  • Affiliates: src/app/(admin)/admin/affiliates/page.tsx

Admin API routes

  • GET /api/admin/users -> paginated users list
  • GET /api/admin/users/[uuid] -> user detail + monthly usage + credits summary
  • PATCH /api/admin/users/[uuid] -> update monthlyCreditsQuota
  • GET /api/admin/users/[uuid]/credits -> credit summary + ledger
  • POST /api/admin/credits/grant -> grant credits
  • POST /api/admin/credits/adjust -> positive/negative credit adjustments
  • GET /api/admin/orders -> paginated orders with status filter (all|paid|created|deleted)

Current setup:

  • Session cookie contains a session token
  • Server checks token against sessions table
  • User resolved from DB-backed session

So this is DB-backed session auth, not pure stateless JWT auth.

Pure JWT model (not current default):

  • Token itself carries claims
  • Server verifies signature each request
  • Usually no DB lookup required for basic validation

10) Why BETTER_AUTH_SECRET Is Still Required

Even when session tokens are random, secret is still needed for cryptographic signing/verification flows.

Better Auth resolves secret from:

  1. Explicit secret option in config
  2. BETTER_AUTH_SECRET
  3. AUTH_SECRET
  4. Internal fallback (dev convenience, not production-safe)

So app may still run without env secret, but that is not a safe production posture.

Use a strong stable secret per environment. Changing secret invalidates existing signed auth artifacts and can log users out.


11) Business Data Modeling: What Belongs Outside Auth Tables

Do not overload auth tables with high-churn business counters.

Keep auth tables auth-focused

  • users: identity/profile/role
  • accounts: login providers + credentials/tokens
  • sessions: login state
  • verifications: one-time verification tokens

Put product/billing state in domain tables

Recommended pattern:

  1. Current-state table (fast reads)
  2. Append-only ledger/events (audit/rebuild)
  3. Entitlements table (feature permits)

Example structures:

  • user_balance

    • user_id (PK)
    • credits_balance
    • quota_limit
    • quota_used
    • plan_id
    • billing_period_start
    • billing_period_end
    • updated_at
  • usage_events

    • id
    • user_id
    • type
    • amount
    • cost_credits
    • metadata (JSON)
    • created_at
  • entitlements

    • user_id
    • key
    • value
    • source (plan/admin/promo)
    • expires_at

This keeps auth stable while business logic stays auditable.


12) Quota, Credits, and Transaction Safety

When spending credits/quota:

  • Use DB transactions
  • Check available balance/quota
  • Insert usage ledger event
  • Apply balance update conditionally

This avoids race conditions and negative balances under concurrency.

In this template:

  • Task usage is tracked in tasks
  • Credit ledger is tracked in credits
  • Monthly quota (users.monthly_credits_quota) is enforced in task orchestration before credit spend

13) Forward Compatibility: Organizations

If you expect team accounts:

  • Add organizations
  • Move balance/quota/entitlements to org level
  • Treat personal accounts as single-member orgs

Designing for org-capable ownership early avoids painful refactors later.


14) Environment and Setup Checklist

Minimum auth-related env:

  • BETTER_AUTH_URL
  • NEXT_PUBLIC_AUTH_BASE_URL
  • BETTER_AUTH_SECRET (strong random)
  • GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET (if Google enabled)

If schema changed, run migrations:

pnpm drizzle-kit generate --config src/db/config.ts
pnpm drizzle-kit migrate --config src/db/config.ts

Final Summary

  • This project uses cookie-based DB sessions (not JWT-first)
  • OAuth identity is provider-account based, not email-domain based
  • Session lifecycle and provider-token lifecycle are separate
  • Auth tables should stay auth-only
  • Credits/quota/permits should live in domain tables with ledger support
  • Admin RBAC is DB role-based and guarded server-side

This architecture gives strong security boundaries, good operational control, and clear scaling paths.