Mastering OAuth: Google, GitHub, & Twitter in Node.js + Angular
Mastering OAuth: Google, GitHub, & Twitter in Node.js + Angular
A complete production guide to unified social login, account linking, and PKCE security
Last updated: December 2025 | Angular 20.2+ | Node.js 20
Building a modern authentication system? Your users expect the convenience of “Continue with Google” or “Continue with GitHub.” But implementing multiple providers while keeping your codebase clean can be a nightmare of spaghetti code.
In this guide, we’ll build a unified, production-grade OAuth system for a Node.js + Angular application. We’re not just hacking together a login button; we’re building a robust architecture that handles:
- Multiple Providers: Google, GitHub, and Twitter (with PKCE).
- Account Linking: Smartly merging accounts based on email addresses.
- Security: State parameters, PKCE, and secure session cookies.
- Extensibility: A pattern that makes adding Facebook or LinkedIn trivial.
By the end of this tutorial, you’ll have a complete, secure social login flow that you can drop into any project.
The Architecture: The Map
Before we write a single line of code, let’s understand the flow. We’re using the Authorization Code Flow, which is the gold standard for security.
Here is how our Frontend (Angular) and Backend (Node.js) will dance together:
graph TB
A[User Clicks 'Login with Google'] --> B[Frontend Requests Auth URL]
B --> C[Backend Generates URL + State]
C --> D[User Redirected to Provider]
D --> E[User Grants Permission]
E --> F[Provider Redirects with Code]
F --> G[Frontend Sends Code to Backend]
G --> H[Backend Exchanges Code for Token]
H --> I[Backend Fetches Profile]
I --> J{User Exists?}
J -->|Yes| K[Update Profile & Link]
J -->|No| L[Create New User]
K --> M[Issue Session Cookies]
L --> M
M --> N[User Logged In]
Why This Architecture?
- Secrets Stay Safe: Your Client Secrets never leave the backend.
- Unified Logic: All providers feed into a single “Upsert User” function.
- Frontend Agnostic: The frontend just handles redirects; the heavy lifting happens on the server.
Prerequisites & Setup
To follow along, you’ll need:
- Node.js 18+ and PostgreSQL.
- Angular 17+ (we’re using Angular 20 features).
- Developer Accounts for Google, GitHub, and Twitter to get your
Client IDandClient Secret.
1. Install Dependencies
First, let’s equip our backend with the necessary tools:
# Backend folder
npm install @prisma/client express cookie-parser cors jsonwebtoken
npm install -D @types/express @types/jsonwebtoken
For the frontend, we’ll rely on Angular’s native HttpClient—no extra bloat needed!
2. Configure Environment Variables
Create a .env file in your backend. Never commit this file!
# Frontend URL (for redirects)
FRONTEND_URL=http://localhost:4200
# Google
GOOGLE_CLIENT_ID=your_google_id
GOOGLE_CLIENT_SECRET=your_google_secret
GOOGLE_REDIRECT_URI=http://localhost:4000/api/auth/social-login
GOOGLE_OAUTH_SCOPES=openid email profile
# GitHub
GITHUB_CLIENT_ID=your_github_id
GITHUB_CLIENT_SECRET=your_github_secret
GITHUB_REDIRECT_URI=http://localhost:4000/api/auth/social-login
GITHUB_OAUTH_SCOPES=read:user user:email
# Twitter (OAuth 2.0)
TWITTER_CLIENT_ID=your_twitter_id
TWITTER_CLIENT_SECRET=your_twitter_secret
TWITTER_REDIRECT_URI=http://localhost:4000/api/auth/social-login
TWITTER_OAUTH_SCOPES=tweet.read users.read offline.access
Pro Tip: Your callback URL (
REDIRECT_URI) must match exactly what you set in the provider’s dashboard, including the port and path.
Part 1: The Database Schema
We need to track which provider a user used and store their unique provider IDs. We’ll use Prisma for our schema.
Add these fields to your User model in schema.prisma:
model User {
id String @id @default(cuid())
email String @unique
password String? // Nullable: OAuth users might not have a password
name String?
avatarUrl String?
emailVerified Boolean @default(false)
// OAuth Tracking
authProvider String? // 'google', 'github', 'twitter'
googleId String? @unique
githubId String? @unique
twitterId String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Why separate IDs?
Storing googleId and githubId separately allows a single user to link both accounts to one profile. If we just had a generic providerId field, a user couldn’t link multiple providers.
Part 2: Centralized Configuration
Hardcoding credentials is a recipe for disaster. Let’s create a type-safe configuration system.
Create src/config/socialProviders.ts:
type BaseConfig = {
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: string[];
};
function ensure(value: string | undefined, key: string): string {
if (!value) throw new Error(`Missing config for ${key}`);
return value;
}
export function resolveGoogleConfig(): BaseConfig {
return {
clientId: ensure(process.env.GOOGLE_CLIENT_ID, 'GOOGLE_CLIENT_ID'),
clientSecret: ensure(process.env.GOOGLE_CLIENT_SECRET, 'GOOGLE_CLIENT_SECRET'),
redirectUri: ensure(process.env.GOOGLE_REDIRECT_URI, 'GOOGLE_REDIRECT_URI'),
scopes: (process.env.GOOGLE_OAUTH_SCOPES || 'openid email profile').split(' '),
};
}
// Repeat for GitHub and Twitter...
This ensures your app fails fast at startup if keys are missing, rather than crashing when a user tries to log in.
Part 3: The Core Service Layer
This is where the magic happens. We need a service that can:
- Generate authorization URLs.
- Exchange codes for tokens.
- Fetch user profiles.
- Standardize the data.
The Standardized Profile
First, let’s define what a “Social Profile” looks like, regardless of whether it came from Google or Twitter.
type SocialProfile = {
provider: 'google' | 'github' | 'twitter';
providerId: string;
email?: string;
name?: string;
avatarUrl?: string;
};
Implementing Google OAuth
// src/services/socialAuthService.ts
export async function handleGoogleCallback(code: string): Promise<User> {
const { clientId, clientSecret, redirectUri } = resolveGoogleConfig();
// 1. Exchange code for token
const tokenParams = new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
});
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: tokenParams,
});
if (!tokenRes.ok) throw new Error('Google token exchange failed');
const { access_token } = await tokenRes.json();
// 2. Fetch Profile
const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${access_token}` },
});
const profileJson = await userRes.json();
// 3. Normalize
const profile: SocialProfile = {
provider: 'google',
providerId: profileJson.sub,
email: profileJson.email,
name: profileJson.name,
avatarUrl: profileJson.picture,
};
return upsertUserFromProfile(profile);
}
Implementing Twitter (The Tricky One: PKCE)
Twitter requires PKCE (Proof Key for Code Exchange), which adds a layer of security. We need to generate a “Code Verifier” and “Code Challenge”.
// Simplified PKCE flow
export function getTwitterAuthorizationUrl() {
const { clientId, redirectUri, scopes } = resolveTwitterConfig();
// Generate random verifier & challenge
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
// Store verifier in memory/redis with a state ID
const stateId = randomUUID();
storeVerifier(stateId, codeVerifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
state: `twitter:${stateId}`,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `https://twitter.com/i/oauth2/authorize?${params.toString()}`;
}
Note: In production, store your PKCE verifiers in Redis or a secure HTTP-only cookie, not just in-memory.
Part 4: Smart Account Linking
The most critical part of this system is the upsertUserFromProfile function. We don’t want to create duplicate accounts if a user logs in with Google one day and GitHub the next, provided they share an email.
async function upsertUserFromProfile(profile: SocialProfile) {
// 1. Check if user exists by Provider ID (The "Login" case)
const providerField = `${profile.provider}Id`;
let user = await prisma.user.findUnique({
where: { [providerField]: profile.providerId }
});
if (user) return user;
// 2. Check if user exists by Email (The "Linking" case)
if (profile.email) {
user = await prisma.user.findUnique({
where: { email: profile.email }
});
if (user) {
// Link the new provider to the existing account
return prisma.user.update({
where: { id: user.id },
data: { [providerField]: profile.providerId }
});
}
}
// 3. Create New User (The "Signup" case)
return prisma.user.create({
data: {
email: profile.email!,
name: profile.name,
avatarUrl: profile.avatarUrl,
[providerField]: profile.providerId,
authProvider: profile.provider,
}
});
}
Part 5: Wiring it Up (Controller & Routes)
Now, let’s expose this logic to our frontend via Express.
The Controller
// src/controllers/socialAuthController.ts
export async function socialLogin(req: Request, res: Response) {
const { provider, code, state } = req.body;
try {
let user;
if (provider === 'google') user = await handleGoogleCallback(code);
else if (provider === 'github') user = await handleGithubCallback(code);
else if (provider === 'twitter') user = await handleTwitterCallback(code, state);
// Issue session cookies (HttpOnly)
const token = signAccessToken(user.id);
res.cookie('token', token, { httpOnly: true, secure: true });
res.json({ user });
} catch (err) {
res.status(400).json({ message: 'Login failed', error: err.message });
}
}
The Routes
// src/routes/socialAuthRoutes.ts
const router = Router();
router.get('/google', (req, res) => res.json({ url: getGoogleAuthorizationUrl() }));
router.get('/github', (req, res) => res.json({ url: getGithubAuthorizationUrl() }));
// The endpoint the frontend calls after the redirect
router.post('/social-login', socialLogin);
export default router;
Part 6: The Frontend Integration
In Angular, we want a clean UI and a robust service.
The Login Page Template
Give your users clear, branded buttons. Using SVGs inside the buttons looks professional.
<!-- login.page.html -->
<div class="login-container">
<h2>Welcome Back</h2>
<div class="social-buttons">
<button class="social-btn google" (click)="onGoogleLogin()">
<!-- SVG Icon for Google -->
Continue with Google
</button>
<button class="social-btn github" (click)="onGithubLogin()">
<!-- SVG Icon for GitHub -->
Continue with GitHub
</button>
<button class="social-btn twitter" (click)="onTwitterLogin()">
<!-- SVG Icon for Twitter -->
Continue with Twitter
</button>
</div>
</div>
The Angular Service
This service handles the initial redirect and the final code exchange.
// auth.service.ts
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
loginWithGoogle() {
this.http.get<{url: string}>('/api/auth/google').subscribe({
next: ({ url }) => window.location.href = url
});
}
// Called in ngOnInit of your login page
handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (code) {
// Clear the ugly URL params
window.history.replaceState({}, '', '/login');
this.http.post('/api/auth/social-login', { code, state }).subscribe({
next: (user) => this.router.navigate(['/dashboard']),
error: (err) => console.error('Login failed', err)
});
}
}
}
Common Pitfalls & Troubleshooting
Even with a perfect guide, things can go wrong. Here are the most common issues I’ve encountered:
-
“GitHub email is missing”:
- Cause: The user’s email is private on GitHub.
- Fix: Ensure you request the
user:emailscope. If it’s still null, you might need to query thehttps://api.github.com/user/emailsendpoint specifically.
-
“Redirect URI Mismatch”:
- Cause: The URL in your code doesn’t exactly match what you put in the Google/GitHub console.
- Fix: Check for trailing slashes (
/) orhttpvshttps. They must be identical.
-
Cookies not setting in production:
- Cause: Cross-domain cookie policies.
- Fix: If your frontend is
app.comand backend isapi.app.com, you must setSameSite: 'none'andSecure: truefor your cookies.
Production Checklist
Before you ship this to users, ensure you have:
- HTTPS Enabled: OAuth generally blocks non-HTTPS redirect URIs (except localhost).
- Separate Credentials: distinct Client IDs for Development, Staging, and Production.
- Rate Limiting: Protect your
/social-loginendpoint from brute-force attempts. - Error Handling: Graceful messages if a user cancels the permissions dialog.
- PKCE: Ensure Twitter and other OAuth 2.0 providers are using PKCE.
Conclusion
You now have a unified, extensible OAuth system. The beauty of this architecture is its flexibility: adding Facebook or LinkedIn next week follows the exact same pattern—add the config, add the service method, and the upsertUserFromProfile handles the rest.
Resources:
- OAuth 2.0 RFC 6749
- PKCE RFC 7636
- Google OAuth Documentation
- GitHub OAuth Documentation
- Twitter OAuth 2.0 Documentation
Happy coding!