Building a Multi-Provider Auth System: From Theory to Working Code
When you’re building a platform that connects developers across multiple services—GitHub, Twitter, LinkedIn—authentication becomes complicated fast. You can’t just slap OAuth on it and call it done. You need to think about how users authenticate, how you store their connections, and what happens when they want to link accounts across platforms.
This is the story of setting up authentication infrastructure for CommitLore, a platform that transforms git commits into developer stories. The specific challenge: supporting both OAuth authentication (starting with GitHub) and multiple connected accounts to social platforms, all while keeping the architecture clean enough to add more providers without breaking everything.
The Core Problem: Identity Fragmentation
Here’s what we were facing:
- Users authenticate via GitHub (OAuth)
- Users want to connect Twitter, LinkedIn, and Dev.to accounts to auto-publish stories
- Each provider has different authentication methods (API keys, browser extensions, app passwords)
- A user might have multiple identities across providers
- We needed to store credentials securely without making the codebase a mess of if-statements
Most tutorials show you how to do single-provider OAuth. But when you’re dealing with multiple identity systems, you quickly realize the pattern needs to be more sophisticated.
The Architecture Decision: Separating Concerns
Instead of having authentication logic scattered across controllers, we split it into three distinct layers:
Domain Layer — The contract that everything else builds on:
// src/modules/auth/domain/interfaces/token-service.interface.ts
export interface ITokenService {
/
* Generate access and refresh tokens for a user
*/
generateTokens(user: { id: string; email?: string; githubId?: string }): Promise;
/
* Verify an access token and return its payload
*/
verifyAccessToken(token: string): Promise;
/
* Verify a refresh token and return its payload
*/
verifyRefreshToken(token: string): Promise;
}
export const TOKEN_SERVICE = 'ITokenService';By defining the interface first, we made it possible to swap implementations later. Testing? Mock the service. Need a different token strategy? Implement the interface differently. This is dependency injection done right.
Application Layer — The use cases that represent actual business logic:
// src/modules/auth/application/use-cases/github-callback.use-case.ts
@Injectable()
export class GitHubCallbackUseCase {
constructor(
private findOrCreateFromOAuthUseCase: FindOrCreateFromOAuthUseCase,
@Inject(TOKEN_SERVICE)
private tokenService: ITokenService,
) {}
async execute(githubUser: GitHubUserData): Promise {
// Prepare OAuth data
const oauthData: OAuthUserData = {
provider: AuthProvider.GITHUB,
providerUserId: githubUser.id.toString(),
providerEmail: githubUser.email,
displayName: githubUser.displayName,
avatarUrl: githubUser.avatarUrl,
isEmailVerified: !!githubUser.email,
providerMetadata: {
accessToken: githubUser.accessToken,
username: githubUser.username,
},
};
// Find or create user
const user = await this.findOrCreateFromOAuthUseCase.execute(oauthData);
// Generate tokens
const tokens = await this.tokenService.generateTokens({
id: user.id,
email: user.email,
githubId: githubUser.id.toString(),
});
return {
user: {
id: user.id,
email: user.email,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
},
tokens,
};
}
}Notice what’s happening here: we’re not handling tokens, not talking to the database, not validating GitHub responses. We’re orchestrating. The use case says: “When GitHub calls us back, find or create a user, then give them tokens.” That’s it.
Infrastructure Layer — The actual implementations:
// src/modules/auth/infrastructure/services/token.service.ts
@Injectable()
export class TokenService implements ITokenService {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
) {}
async generateTokens(user: { id: string; email?: string; githubId?: string }): Promise {
const payload: TokenPayload = {
sub: user.id,
email: user.email,
githubId: user.githubId,
};
// Generate access token (short-lived - 15 minutes)
const accessToken = this.jwtService.sign(payload, {
secret: this.configService.get('JWT_SECRET'),
expiresIn: '15m',
});
// Generate refresh token (long-lived - 7 days)
const refreshToken = this.jwtService.sign(payload, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
expiresIn: '7d',
});
return {
accessToken,
refreshToken,
};
}
async verifyAccessToken(token: string): Promise {
try {
const payload = this.jwtService.verify(token, {
secret: this.configService.get('JWT_SECRET'),
});
return payload;
} catch {
return null;
}
}
// ... refresh token verification
}The Database Design: Future-Proof Identity Storage
Here’s where the multi-provider support becomes tangible. We have three key tables:
Users — The core user record:
CREATE TABLE "users" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
"email" character varying(255),
"displayName" character varying(255),
"avatarUrl" text,
"isActive" boolean NOT NULL DEFAULT true,
CONSTRAINT "UQ_users_email" UNIQUE ("email"),
CONSTRAINT "PK_users_id" PRIMARY KEY ("id")
)Auth Identities — The multi-provider links:
CREATE TABLE "auth_identities" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"userId" uuid NOT NULL,
"provider" character varying(50) NOT NULL,
"providerUserId" character varying(255) NOT NULL,
"providerEmail" character varying(255),
"passwordHash" text,
"providerMetadata" jsonb,
CONSTRAINT "FK_auth_identities_userId" FOREIGN KEY ("userId")
REFERENCES "users"("id") ON DELETE CASCADE,
CONSTRAINT "UQ_provider_providerUserId" UNIQUE ("provider", "providerUserId")
)The key insight: one user can have multiple auth identities. A user might log in via GitHub, but we’re already prepared for them to authenticate via Google or email+password later.
Connected Accounts — The social platforms:
CREATE TYPE "platform_enum" AS ENUM ('twitter', 'linkedin', 'wordpress', 'devto');
CREATE TYPE "connection_method_enum" AS ENUM ('api_credentials', 'browser_extension', 'app_password', 'api_key');
CREATE TABLE "connected_accounts" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"userId" uuid NOT NULL,
"platform" "platform_enum" NOT NULL,
"connectionMethod" "connection_method_enum" NOT NULL,
"displayName" character varying(255),
"profileUrl" character varying(500),
"isActive" boolean NOT NULL DEFAULT true,
"credentials" jsonb NOT NULL DEFAULT '{}',
CONSTRAINT "FK_connected_accounts_userId" FOREIGN KEY ("userId")
REFERENCES "users"("id") ON DELETE CASCADE,
CONSTRAINT "UQ_userId_platform" UNIQUE ("userId", "platform")
)This separation means:
- A user authenticates once via GitHub
- They can connect unlimited social platforms
- Each platform can use different credential types
- The data structure scales without refactoring
What Made This Click
The commit includes 61 files and roughly 28k lines. That’s not because it’s bloated—it’s because the architecture had to be right from the start. Here’s why:
1. Token Strategy is Pluggable — We’re using a 15-minute access token + 7-day refresh token pattern. But if we needed to switch to session-based auth or a different token library? The interface stays the same. The implementation changes.
2. OAuth Integration is Modular — GitHub is handled through Passport strategies, but the business logic (find or create user) is completely separate from the OAuth mechanism. Adding Twitter OAuth means implementing a new strategy, not rewriting the user service.
3. Database Migrations are Explicit — Instead of relying on ORM synchronization (which breaks in production), each schema change is a timestamped migration. This matters when you have multiple providers with different credential formats.
4. Identity Providers are Enumerated — By using TypeScript enums and database enums for provider types, we prevent invalid states. No accidental typos like “githb” sneaking into production.
What Didn’t Make It (And Why)
This commit doesn’t include the actual repository sync logic, webhook handlers, or the frontend. Those come in separate PRs. There’s a reason: this is foundational. Authentication can’t be done incrementally. You build it solid, deploy it, then layer features on top.
We also didn’t include email/password authentication. GitHub OAuth was the MVP. Adding email later will be trivial because we already have AuthProvider.EMAIL ready to go in the enum.
The Moment It Clicked
When you can write a use case like this and not think about tokens, database queries, or HTTP details—that’s when you know the architecture is right:
async execute(githubUser: GitHubUserData): Promise {
const oauthData: OAuthUserData = { / ... / };
const user = await this.findOrCreateFromOAuthUseCase.execute(oauthData);
const tokens = await this.tokenService.generateTokens({ / ... / });
return { user, tokens };
}This is pure business logic. No framework noise. No SQL. No HTTP concerns. That’s the point.
For Your Next Project
If you’re building multi-provider auth, steal this pattern:
1. Define interfaces first — Know what your token service needs to do before you implement it
2. Separate identity providers from connected accounts — They’re different concerns
3. Use migrations for schema changes — Even if it feels slower upfront
4. Keep use cases simple — If your use case is orchestrating more than 3 things, split it
5. Plan for multiple providers early — Even if you only implement one initially
The code doesn’t need to be complex. The thinking needs to be clear.

Aniefon Umanah
Full Stack Engineer with 7+ years of experience. I write about software development, architecture patterns, and lessons learned from building real-world applications.