Better Auth User Module Consolidation

Overview

The Bun backend’s user module has been consolidated to delegate all authentication and user lifecycle operations directly to Better Auth’s server API. This eliminates redundant custom code, simplifies the codebase, and provides a single source of truth for user management.

Architecture Decision

Previously, the system maintained parallel implementations:
  • Custom domain layer: RegisterUserUseCase, LoginUserUseCase, UpdateUserProfileUseCase, GetUserUseCase
  • Custom repository: MongoDB-based UserRepository for persistence
  • Custom password handling: PasswordService with custom hashing logic
Current approach: All user operations delegate directly to Better Auth APIs via authPort, with route handlers calling these APIs transparently.

Updated Component Model

Before Consolidation

Route Handler

Use Case (e.g., RegisterUserUseCase)

Repository (UserRepository - MongoDB)

MongoDB

After Consolidation

Route Handler

authPort.signUp.email() / auth.api.* (Better Auth)

PostgreSQL (Better Auth managed)

Core Components

1. authPort (Ports)

Defined in @bun-core/auth-ports, the minimal interface for authentication operations:
interface AuthPort {
  signUp: {
    email(input: { email: string; password: string }): Promise<SignUpResponse>;
  };
  signIn: {
    email(input: { email: string; password: string }): Promise<SignInResponse>;
  };
}
Key point: authPort methods call Better Auth’s server API directly. No custom use cases between route and API.

2. Better Auth Adapter

Defined in @bun-core/auth-adapter, the adapter bridges route handlers to Better Auth:
// better-auth.adapter.ts
export const signUp = {
  email: async (input) => {
    return await auth.api.signUpEmail(input); // Direct delegation
  }
};

export const signIn = {
  email: async (input) => {
    return await auth.api.signInEmail(input); // Direct delegation
  }
};
Key point: Adapter is a thin bridge. No business logic here—just delegation.

3. Route Handlers

Route handlers call authPort directly without intermediate use cases:
// POST /api/v1/users/register
export const register = async (c: Context) => {
  const { email, password } = await c.req.json();
  
  const result = await authPort.signUp.email({ email, password });
  
  return c.json(result);
};
Key point: Direct API call. No use case wrapper needed.

4. Admin & Organization APIs

For user management operations (update profile, get user, manage organization roles), use Better Auth’s admin plugin:
// Update user profile
const updated = await auth.api.adminUpdateUser({
  body: { userId, data: { name: "New Name" } }
});

// Get user with roles
const user = await auth.api.getUser({ query: { id: userId } });

// Get organization membership
const orgs = await auth.api.getOrganizations({ query: { userId } });
Key point: Better Auth’s admin and organization plugins handle all user context.

Database Schema

Better Auth manages these PostgreSQL tables:
TablePurpose
usersCore user data: email, name, image, emailVerified, isAnonymous, timestamps
accountsOAuth and email/password credentials: hashed passwords, provider tokens
sessionsActive sessions: token, expiration, IP address, user agent
verificationsEmail verification codes and tokens
organizationsOrganization entities: name, slug, metadata, timestamps
membersUser organization membership with per-org roles: owner, admin, member, staff, viewer
invitationsOrganization invitations: email, role, status, expiration
jwksJWT key set (Better Auth JWT plugin)
Key point: All these tables are owned and managed by Better Auth. No custom migration scripts needed.

Role Management

Platform Roles

Platform-level roles (user, admin, etc.) are stored in the users.role column from Better Auth’s admin plugin:
// User has a single platform role
const user = await auth.api.getUser({ query: { id: userId } });
console.log(user.role); // "user" | "admin" | etc.

Organization Roles

Organization-specific roles (owner, admin, member, staff, viewer) are managed via the members table:
// Get user's roles within an organization
const members = await auth.api.getMembersByOrganization({
  query: { organizationId }
});

const userMember = members.find(m => m.userId === userId);
console.log(userMember.role); // "owner" | "admin" | "member" | etc.
Key point: Platform roles and org roles are separate concerns. Query as needed for your business logic.

Authentication Flow

Registration

  1. Client Request: POST /api/v1/users/register { email, password }
  2. Route Handler: Calls authPort.signUp.email({ email, password })
  3. Better Auth:
    • Validates email uniqueness
    • Hashes password with scrypt
    • Creates auth record
    • Creates accounts record with password hash
    • Returns user + session token
  4. Response: Returns JWT and user data

Login

  1. Client Request: POST /api/v1/users/login { email, password }
  2. Route Handler: Calls authPort.signIn.email({ email, password })
  3. Better Auth:
    • Queries auth table by email
    • Fetches password hash from accounts
    • Verifies password (scrypt)
    • Creates session if valid
    • Returns user + JWT
  4. Response: Returns JWT and user data

User Retrieval

  1. Route Handler: GET /api/v1/users/me
  2. Middleware: Extracts JWT, verifies, attaches user to context
  3. Handler: Calls auth.api.getUser({ query: { id: userId } })
  4. Better Auth: Returns user data from auth table
  5. Response: Returns user with platform role

Profile Update

  1. Client Request: PATCH /api/v1/users/me { name, image }
  2. Route Handler: Calls auth.api.adminUpdateUser({ body: { userId, data } })
  3. Better Auth: Updates auth table, returns updated user
  4. Response: Returns updated user data

Password Hashing

Better Auth uses scrypt for password hashing (Better Auth default).
If you need to handle production password migration from Argon2id (MongoDB) to scrypt (Better Auth), implement a checkPassword hook in the adapter for lazy re-hashing on first login. However, for development-only environments with no production data, this is not needed.
ComponentPathPurpose
Auth Port@bun-core/auth-ports/auth.port.tsInterface for auth operations
Auth Adapter@bun-core/auth-adapter/better-auth.adapter.tsBetter Auth bridge
JWT Service@bun-core/auth-adapter/jwt.service.tsJWT signing/verification with enrichment
Auth Schema@bun-core/auth-adapter/schema.tsDrizzle schema for Better Auth tables
Auth Middlewareapps/monolith/src/middleware/auth.middleware.tsHono middleware for token extraction

Key Takeaways

  1. Simplified code: No custom use cases, repositories, or password logic
  2. Single source of truth: Better Auth owns all user state in PostgreSQL
  3. Direct delegation: Route handlers call Better Auth API directly via authPort
  4. Clean separation: Better Auth handles persistence, adapters handle bridging, routes handle HTTP

Removed Components

  • RegisterUserUseCase (now direct authPort.signUp.email() call)
  • LoginUserUseCase (now direct authPort.signIn.email() call)
  • UpdateUserProfileUseCase (now direct auth.api.adminUpdateUser() call)
  • GetUserUseCase (now direct auth.api.getUser() call)
  • UserRepository MongoDB implementation (Better Auth handles persistence)
  • PasswordService (Better Auth owns password hashing)
  • ❌ Custom password hashing logic (scrypt via Better Auth)

Next Steps & Future Phases

Phase 2: Email Infrastructure

  • Implement email service (SMTP or third-party)
  • Enable email verification: set requireEmailVerification: true
  • Implement forgot password flow via auth.api.recoveryEmail()
  • Implement password reset flow

Phase 3: Advanced Features

  • Two-factor authentication (Better Auth has 2fa plugin)
  • User deactivation (add isActive column to auth table if needed)
  • Session management API
  • Role-based access control (RBAC) enforcement
  • Audit logging for auth events