Now that you understand the OAuth 2.0What is oauth?An authorization protocol that lets users grant a third-party app limited access to their account on another service without sharing their password. flow conceptually, it is time to wire it up for real providers. Each providerWhat is provider?A wrapper component that makes data available to all components nested inside it without passing props manually. follows the same overall pattern but differs in endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. URLs, scopeWhat is scope?The area of your code where a variable is accessible; variables declared inside a function or block are invisible outside it. syntax, and what fields appear in the user info response. This lesson walks through Google, GitHub, and Discord so you can confidently add any new provider by reading its docs.
Setting up Google OAuthWhat is oauth?An authorization protocol that lets users grant a third-party app limited access to their account on another service without sharing their password.
Google is the most feature-rich OAuth providerWhat is provider?A wrapper component that makes data available to all components nested inside it without passing props manually. and the closest to a pure OIDC implementation. Before writing any code, you need credentials from the Google Cloud Console.
To get your credentials, go to APIs and Services, then Credentials, and create an OAuth 2.0 Client ID. You will need to configure the consent screen first, set the app name, support email, and the authorized domains your app will run on. Then add your redirect URIs, including a localhost one for development.
Google's user info response follows the OIDC standard:
// Google returns standardized OpenID Connect claims
const googleUser = {
id: '123456789',
email: '[email protected]',
name: 'John Doe',
picture: 'https://lh3.googleusercontent.com/...',
verified_email: true
};Setting up GitHub OAuthWhat is oauth?An authorization protocol that lets users grant a third-party app limited access to their account on another service without sharing their password.
GitHub does not support OpenID ConnectWhat is openid connect?A layer on top of OAuth 2.0 that adds user authentication, returning an ID token with verified identity information like email and name., so you do not get an id_token. You will need to make a separate APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. call to fetch the user's profile after exchanging the code.
Register your app under Settings, then Developer settings, then OAuth Apps. One key difference: GitHub uses comma-separated scopes, not space-separated.
// GitHub OAuth URLs
const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const GITHUB_API_URL = 'https://api.github.com';
// Authorization request - note comma-separated scopes
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: 'http://localhost:3000/auth/github/callback',
scope: 'read:user,user:email', // Comma-separated, not space-separated!
state: generateState()
});
// Fetch user info after token exchange
const userResponse = await fetch(`${GITHUB_API_URL}/user`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json'
}
});/user returns email: null. You need to call /user/emails separately and find the primary verified email. Always handle this edge case.Setting up Discord OAuthWhat is oauth?An authorization protocol that lets users grant a third-party app limited access to their account on another service without sharing their password.
Discord uses space-separated scopes like Google, but has its own CDNWhat is cdn?Content Delivery Network - a network of servers around the world that caches your files and serves them from the location closest to the user, making pages load faster. URL format for avatar images. The identify scopeWhat is scope?The area of your code where a variable is accessible; variables declared inside a function or block are invisible outside it. gives you basic profile info and email gives you the email address.
const DISCORD_AUTH_URL = 'https://discord.com/api/oauth2/authorize';
const DISCORD_TOKEN_URL = 'https://discord.com/api/oauth2/token';
const DISCORD_API_URL = 'https://discord.com/api/v10';
// Authorization
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
redirect_uri: 'http://localhost:3000/auth/discord/callback',
response_type: 'code',
scope: 'identify email',
state: generateState()
});
// User info - avatar requires constructing the URL manually
const userResponse = await fetch(`${DISCORD_API_URL}/users/@me`, {
headers: { Authorization: `Bearer ${accessToken}` }
});Handling the OAuthWhat is oauth?An authorization protocol that lets users grant a third-party app limited access to their account on another service without sharing their password. callbackWhat is callback?A function you pass into another function to be called later, often when an operation finishes or an event occurs.
Your callback endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. is where most of the complexity lives. Users do not always grant access, networks fail, and state parameters can be tampered with. Handle all of these cases explicitly:
app.get('/auth/:provider/callback', async (req, res) => {
const { provider } = req.params;
const { code, state, error, error_description } = req.query;
// 1. Handle user denial
if (error === 'access_denied') {
return res.redirect('/login?error=user_denied');
}
// 2. Handle other provider errors
if (error) {
console.error('OAuth error:', error_description);
return res.redirect('/login?error=oauth_failed');
}
// 3. Validate state parameter (CSRF protection)
if (state !== req.session.oauthState) {
return res.status(403).send('Invalid state parameter');
}
// 4. Exchange code for tokens and fetch user info
try {
const tokens = await exchangeCodeForTokens(provider, code);
const userInfo = await fetchUserInfo(provider, tokens.access_token);
// 5. Link or create user account
const user = await findOrCreateUser(provider, userInfo);
// 6. Create session and redirect
req.session.userId = user.id;
res.redirect('/dashboard');
} catch (err) {
console.error('OAuth callback error:', err);
res.redirect('/login?error=authentication_failed');
}
});Account linking strategy
When a user who already has an email-and-password account clicks "Sign in with GitHub", you have three options: reject them, force them to merge manually, or link automatically if the email matches and is verified. Automatic linking by verified email is the friendliest approach for users.
async function findOrCreateUser(provider, oauthUser) {
// Strategy 1: user already linked this provider
let user = await db.findUserByOAuthId(provider, oauthUser.id);
if (user) return user;
// Strategy 2: link by verified email to an existing account
if (oauthUser.verified_email || oauthUser.verified) {
user = await db.findUserByEmail(oauthUser.email);
if (user) {
await db.linkOAuthAccount(user.id, {
provider,
providerId: oauthUser.id,
email: oauthUser.email
});
return user;
}
}
// Strategy 3: create a brand new account
return await db.createUser({
email: oauthUser.email,
name: oauthUser.name,
avatar: oauthUser.picture || oauthUser.avatar_url,
oauthAccounts: [{
provider,
providerId: oauthUser.id,
email: oauthUser.email
}]
});
}Storing multiple OAuthWhat is oauth?An authorization protocol that lets users grant a third-party app limited access to their account on another service without sharing their password. providers
Users often want to sign in with different providers on different devices. The cleanest way to support this is a dedicated oauth_accounts table that links providerWhat is provider?A wrapper component that makes data available to all components nested inside it without passing props manually. identities to a single user row:
-- One user can have multiple OAuth connections
CREATE TABLE oauth_accounts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL, -- 'google', 'github', 'discord'
provider_id VARCHAR(255) NOT NULL,
email VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(provider, provider_id) -- Each provider+id pair is unique
);Normalizing user data across providers
Every providerWhat is provider?A wrapper component that makes data available to all components nested inside it without passing props manually. returns a slightly different shape of user object. Normalizing early means the restWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources. of your code never has to care which provider was used:
function normalizeUserInfo(provider, rawInfo) {
const normalizers = {
google: (info) => ({
id: info.id,
email: info.email,
name: info.name,
avatar: info.picture,
verified: info.verified_email
}),
github: (info) => ({
id: info.id.toString(),
email: info.email,
name: info.name || info.login, // login is the @username fallback
avatar: info.avatar_url,
verified: true // GitHub requires email verification
}),
discord: (info) => ({
id: info.id,
email: info.email,
name: info.global_name || info.username,
avatar: info.avatar
? `https://cdn.discordapp.com/avatars/${info.id}/${info.avatar}.png`
: null,
verified: info.verified
})
};
return normalizers[provider](rawInfo);
}Quick reference
| Provider | Auth URL | Token URL | Scope format | Returns id_token? |
|---|---|---|---|---|
accounts.google.com/o/oauth2/v2/auth | oauth2.googleapis.com/token | Space-separated | Yes (OIDC) | |
| GitHub | github.com/login/oauth/authorize | github.com/login/oauth/access_token | Comma-separated | No |
| Discord | discord.com/api/oauth2/authorize | discord.com/api/oauth2/token | Space-separated | No |
// Multi-provider OAuth implementation
class OAuthManager {
constructor() {
this.providers = {
google: {
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo',
scopes: 'openid email profile'
},
github: {
authUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scopes: 'read:user user:email'
},
discord: {
authUrl: 'https://discord.com/api/oauth2/authorize',
tokenUrl: 'https://discord.com/api/oauth2/token',
userInfoUrl: 'https://discord.com/api/v10/users/@me',
scopes: 'identify email'
}
};
}
getAuthUrl(provider, redirectUri) {
const config = this.providers[provider];
const state = crypto.randomBytes(32).toString('hex');
const params = new URLSearchParams({
client_id: process.env[`${provider.toUpperCase()}_CLIENT_ID`],
redirect_uri: redirectUri,
response_type: 'code',
scope: config.scopes,
state: state
});
// Google-specific: request refresh token
if (provider === 'google') {
params.set('access_type', 'offline');
params.set('prompt', 'consent');
}
return { url: `${config.authUrl}?${params}`, state };
}
async exchangeCode(provider, code, redirectUri) {
const config = this.providers[provider];
const clientId = process.env[`${provider.toUpperCase()}_CLIENT_ID`];
const clientSecret = process.env[`${provider.toUpperCase()}_CLIENT_SECRET`];
const response = await fetch(config.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code'
})
});
return await response.json();
}
normalizeUserInfo(provider, rawInfo) {
// Normalize different provider formats to consistent structure
const normalizers = {
google: (info) => ({
id: info.id,
email: info.email,
name: info.name,
avatar: info.picture,
verified: info.verified_email
}),
github: (info) => ({
id: info.id.toString(),
email: info.email,
name: info.name || info.login,
avatar: info.avatar_url,
verified: true // GitHub requires verified email for OAuth
}),
discord: (info) => ({
id: info.id,
email: info.email,
name: info.global_name || info.username,
avatar: info.avatar
? `https://cdn.discordapp.com/avatars/${info.id}/${info.avatar}.png`
: null,
verified: info.verified
})
};
return normalizers[provider](rawInfo);
}
}