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:
| Table | Purpose |
|---|
| users | Core user data: email, name, image, emailVerified, isAnonymous, timestamps |
| accounts | OAuth and email/password credentials: hashed passwords, provider tokens |
| sessions | Active sessions: token, expiration, IP address, user agent |
| verifications | Email verification codes and tokens |
| organizations | Organization entities: name, slug, metadata, timestamps |
| members | User organization membership with per-org roles: owner, admin, member, staff, viewer |
| invitations | Organization invitations: email, role, status, expiration |
| jwks | JWT 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-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
- Client Request:
POST /api/v1/users/register { email, password }
- Route Handler: Calls
authPort.signUp.email({ email, password })
- Better Auth:
- Validates email uniqueness
- Hashes password with scrypt
- Creates
auth record
- Creates
accounts record with password hash
- Returns user + session token
- Response: Returns JWT and user data
Login
- Client Request:
POST /api/v1/users/login { email, password }
- Route Handler: Calls
authPort.signIn.email({ email, password })
- Better Auth:
- Queries
auth table by email
- Fetches password hash from
accounts
- Verifies password (scrypt)
- Creates session if valid
- Returns user + JWT
- Response: Returns JWT and user data
User Retrieval
- Route Handler:
GET /api/v1/users/me
- Middleware: Extracts JWT, verifies, attaches user to context
- Handler: Calls
auth.api.getUser({ query: { id: userId } })
- Better Auth: Returns user data from
auth table
- Response: Returns user with platform role
Profile Update
- Client Request:
PATCH /api/v1/users/me { name, image }
- Route Handler: Calls
auth.api.adminUpdateUser({ body: { userId, data } })
- Better Auth: Updates
auth table, returns updated user
- 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.
| Component | Path | Purpose |
|---|
| Auth Port | @bun-core/auth-ports/auth.port.ts | Interface for auth operations |
| Auth Adapter | @bun-core/auth-adapter/better-auth.adapter.ts | Better Auth bridge |
| JWT Service | @bun-core/auth-adapter/jwt.service.ts | JWT signing/verification with enrichment |
| Auth Schema | @bun-core/auth-adapter/schema.ts | Drizzle schema for Better Auth tables |
| Auth Middleware | apps/monolith/src/middleware/auth.middleware.ts | Hono middleware for token extraction |
Key Takeaways
- Simplified code: No custom use cases, repositories, or password logic
- Single source of truth: Better Auth owns all user state in PostgreSQL
- Direct delegation: Route handlers call Better Auth API directly via
authPort
- 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