Centralizing Account Management: Moving from Brittle Role Profiles to Unified Identity
In the early stages of building a multi-tenant web application, handling user roles feels straightforward. You have standard customers, internal administrators, and external partner accounts. The simplest approach? Save credentials, activation flags, and verification timestamps directly on each role's specific profile collection.
While this gets you off the ground, it quickly hits a scalability wall. As the application grows, distributed identity leads to duplicate validation flows, synchronization bugs, and auditing challenges. Here is how you can refactor a brittle, role-split identity schema into a unified, scalable account management service.
The Problem with Distributed Identity
When auth states live directly within role-specific profiles (e.g., a customers collection, a partners collection, and an administrators collection), identity properties become scattered across the system.
1. The Hashing & Verification Mess
Every time you add a new role or modify registration logic, you end up duplicating authentication concerns:
- Checking whether a credential has been configured (e.g.,
isPasswordConfigured). - Verifying whether an email has been confirmed.
- Checking if the account is active or suspended.
These properties are infrastructure concerns, but they are mixed in with domain details like billing addresses, custom preferences, or business relationships.
2. Synchronization Headaches
If a guest or external partner is later registered as a full customer, you must synchronize their credentials across collections. Keeping data in sync across distinct, schema-split documents is notoriously error-prone, especially in NoSQL environments without native transactions.
3. High Audit Complexity
To suspend a user's login access globally, an administrator has to run updates against every individual role table or write complex multi-collection queries.
The Architecture: Unifying Identity
The solution is to separate Identity (authentication and infrastructure) from Profile (domain-specific data). A single accounts collection represents a user's core credentials and verification status, linking back to their role-specific details using a reference pointer.
┌──────────────────────────────┐
│ Unified Accounts Collection │
└──────────────┬───────────────┘
│
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│Customer Profiles │ │ Partner Profiles │ │ Admin Profiles │
└──────────────────┘ └──────────────────┘ └──────────────────┘
The Unified Account Schema Example
The centralized accounts collection stores only core identity properties:
interface UserAccount {
id: string;
profileId: string; // Links back to the role-specific profile document
email: string; // Normalized unique identifier
passwordHash?: string; // Hashed password
hasCredentialsSet: boolean;
emailConfirmedAt: Date | null;
roleType: 'customer' | 'partner' | 'admin';
accountStatus: 'active' | 'suspended' | 'pending';
auditLog: AuditLogEntry[];
}
The Refactoring Journey
Refactoring a production database schema demands extreme care. You cannot simply drop columns or break existing APIs. The migration must be gradual, backwards-compatible, and verifiable.
Step 1: Idempotent Database Migration
A safe migration script processes existing role records and maps their legacy credentials over to the unified accounts table without overwriting newer, active accounts:
// A conceptual example of a safe, idempotent migration script
const existingAccount = await db.accounts.findOne({
$or: [{ profileId: record.id }, { email: normalizedEmail }]
});
if (!existingAccount) {
await db.accounts.insertOne({
profileId: record.id,
email: normalizedEmail,
passwordHash: record.password_hash,
hasCredentialsSet: record.password_hash ? true : false,
emailConfirmedAt: record.email_confirmed_at || null,
roleType: 'partner',
accountStatus: 'active',
createdAt: new Date(),
});
}
Once the records are verified as migrated, legacy properties can be safely cleaned up or unset from the profile collections.
Step 2: Centralizing Authentication Checks
Instead of duplicating active checks and password validation loops in different modules, centralize these concerns into a reusable IdentityService:
export class IdentityService {
async verifyAuthStatus(email: string): Promise<{ success: boolean; error?: string }> {
const normalizedEmail = email.trim().toLowerCase();
const account = await db.accounts.findOne({ email: normalizedEmail });
if (account) {
if (!account.hasCredentialsSet) {
return { success: false, error: 'Credentials not configured' };
}
if (account.accountStatus !== 'active') {
return { success: false, error: 'Account is not active' };
}
}
return { success: true };
}
}
In your authentication middleware or validation routines, delegate status checks entirely to the centralized service:
// Example usage in an authentication handler
const authStatus = await identityService.verifyAuthStatus(email);
if (!authStatus.success) {
return { success: false, error: authStatus.error };
}
Step 3: Streamlining Creation Flows
When registering any new customer, partner, or administrator, call the unified IdentityService to construct the credentials document alongside the profile document:
// 1. Create Profile record (storing only domain attributes)
const profile = await db.profiles.insertOne(profilePayload);
// 2. Delegate Account creation
await identityService.createAccount({
profileId: profile.id,
email: normalizedEmail,
passwordHash: hashedPassword,
roleType: 'customer',
});
Key Benefits Realized
1. Consistent Rules
Whether a user logs in through OAuth, traditional password forms, or temporary passwordless links, they all resolve through the exact same status checks and security policies.
2. Clean Code Auditing
Security audits are now simple. Checking if a user has configured their credentials or needs to re-verify their email requires looking at a single collection, regardless of their role.
3. Safer Schema Evolutions
If you need to introduce Multi-Factor Authentication (MFA), password rotation policies, or change password hashing algorithms (e.g. migrating from bcrypt to argon2), you only need to update the accounts collection schema and its service layer. The role profile layers remain completely untouched.
Closing Thoughts
Unified identity isn't just about keeping database tables clean—it is about establishing a clear separation of concerns. Authentication and account states are system infrastructure, whereas profile details are domain-level payloads.
By decoupling these layers early, your codebase becomes highly testable, onboarding new user roles becomes trivial, and your security policies remain robust and centralized.
Questions for Reflection
- Does your current database schema mix profile data with authentication states?
- How complex is it in your application to suspend a user's access across different modules?
- How easily can you change your password hashing strategy without affecting domain services?
Recommended Reading
- Single Source of Truth (SSOT) - Designing information models so that every data element is mastered in one place.
- OAuth 2.0 Identity Architecture - Best practices on modeling authorization and identity records.
- Clean Architecture (Robert C. Martin) - Concepts on decoupling domain policies from system infrastructure.
