Introduction
Adding OAuth login to your website allows users to authenticate using existing accounts from Google, GitHub, Facebook, or other identity providers. This guide provides practical code examples for implementing OAuth 2.0 authentication in web applications, from basic setup to production-ready implementations.
OAuth Flow Overview
Authorization Code Flow with PKCE
For web applications, the recommended flow is Authorization Code Flow with PKCE (Proof Key for Code Exchange):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ OAuth 2.0 Authorization Code + PKCE โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ 1. User clicks "Login with Google" โ
โ โ โ
โ โผ โ
โ 2. Generate code_verifier (random string) โ
โ Generate code_challenge = sha256(code_verifier) โ
โ โ โ
โ โผ โ
โ 3. Redirect to Google OAuth: โ
โ https://accounts.google.com/o/oauth2/v2/auth? โ
โ client_id=YOUR_CLIENT_ID โ
โ redirect_uri=YOUR_CALLBACK_URL โ
โ response_type=code โ
โ scope=openid email profile โ
โ code_challenge=XXX โ
โ code_challenge_method=S256 โ
โ state=random_string โ
โ โ โ
โ โผ โ
โ 4. User logs in at Google, authorizes your app โ
โ โ โ
โ โผ โ
โ 5. Google redirects to your callback with: โ
โ ?code=AUTH_CODE&state=random_string โ
โ โ โ
โ โผ โ
โ 6. Your server exchanges code for tokens: โ
โ POST https://oauth2.googleapis.com/token โ
โ code=AUTH_CODE โ
โ client_id=YOUR_CLIENT_ID โ
โ code_verifier=code_verifier โ
โ grant_type=authorization_code โ
โ โ โ
โ โผ โ
โ 7. Google returns: โ
โ { access_token, refresh_token, id_token, expires_in } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Server-Side Implementation
Node.js/Express Implementation
// server.js
const express = require('express');
const crypto = require('crypto');
const axios = require('axios');
const session = require('express-session');
const app = express();
// Configuration
const config = {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: 'http://localhost:3000/auth/google/callback',
authorizationUrl: 'https://oauth2.googleapis.com/oauth2/v4/authorize',
tokenUrl: 'https://oauth2.googleapis.com/token',
userInfoUrl: 'https://www.googleapis.com/oauth2/v3/userinfo'
},
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
redirectUri: 'http://localhost:3000/auth/github/callback',
authorizationUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user'
}
};
// Session setup
app.use(session({
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Generate PKCE code verifier and challenge
function generatePKCE() {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return { codeVerifier, codeChallenge };
}
// Generate random state
function generateState() {
return crypto.randomBytes(16).toString('hex');
}
// Login with Google
app.get('/auth/google', (req, res) => {
const { codeVerifier, codeChallenge } = generatePKCE();
const state = generateState();
// Store in session for verification
req.session.oauthState = state;
req.session.codeVerifier = codeVerifier;
req.session.provider = 'google';
const params = new URLSearchParams({
client_id: config.google.clientId,
redirect_uri: config.google.redirectUri,
response_type: 'code',
scope: 'openid email profile',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: state
});
res.redirect(`${config.google.authorizationUrl}?${params}`);
});
// Login with GitHub
app.get('/auth/github', (req, res) => {
const state = generateState();
req.session.oauthState = state;
req.session.provider = 'github';
const params = new URLSearchParams({
client_id: config.github.clientId,
redirect_uri: config.github.redirectUri,
scope: 'read:user user:email',
state: state
});
res.redirect(`${config.github.authorizationUrl}?${params}`);
});
// Google callback
app.get('/auth/google/callback', async (req, res) => {
try {
const { code, state } = req.query;
// Verify state
if (state !== req.session.oauthState) {
return res.status(400).send('Invalid state parameter');
}
// Exchange code for tokens
const tokenResponse = await axios.post(config.google.tokenUrl, {
code: code,
client_id: config.google.clientId,
client_secret: config.google.clientSecret,
code_verifier: req.session.codeVerifier,
grant_type: 'authorization_code',
redirect_uri: config.google.redirectUri
});
const { access_token, id_token, refresh_token, expires_in } = tokenResponse.data;
// Get user info
const userInfoResponse = await axios.get(config.google.userInfoUrl, {
headers: { Authorization: `Bearer ${access_token}` }
});
const user = userInfoResponse.data;
// Create session
req.session.user = {
id: user.sub,
email: user.email,
name: user.name,
picture: user.picture,
provider: 'google'
};
req.session.accessToken = access_token;
req.session.refreshToken = refresh_token;
// Clean up temp session data
delete req.session.oauthState;
delete req.session.codeVerifier;
res.redirect('/dashboard');
} catch (error) {
console.error('OAuth error:', error.response?.data || error.message);
res.status(500).send('Authentication failed');
}
});
// GitHub callback
app.get('/auth/github/callback', async (req, res) => {
try {
const { code, state } = req.query;
if (state !== req.session.oauthState) {
return res.status(400).send('Invalid state parameter');
}
// Exchange code for token
const tokenResponse = await axios.post(config.github.tokenUrl, {
code: code,
client_id: config.github.clientId,
client_secret: config.github.clientSecret,
redirect_uri: config.github.redirectUri
}, {
headers: { Accept: 'application/json' }
});
const { access_token } = tokenResponse.data;
// Get user info
const [userResponse, emailsResponse] = await Promise.all([
axios.get(config.github.userInfoUrl, {
headers: { Authorization: `Bearer ${access_token}` }
}),
axios.get('https://api.github.com/user/emails', {
headers: { Authorization: `Bearer ${access_token}` }
})
]);
const user = userResponse.data;
const primaryEmail = emailsResponse.data.find(e => e.primary)?.email || emailsResponse.data[0]?.email;
req.session.user = {
id: user.id.toString(),
email: primaryEmail || user.email,
name: user.name || user.login,
picture: user.avatar_url,
provider: 'github'
};
req.session.accessToken = access_token;
delete req.session.oauthState;
res.redirect('/dashboard');
} catch (error) {
console.error('GitHub OAuth error:', error.response?.data || error.message);
res.status(500).send('Authentication failed');
}
});
// Logout
app.get('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Session destroy error:', err);
}
res.redirect('/');
});
});
// Protected route example
app.get('/dashboard', (req, res) => {
if (!req.session.user) {
return res.redirect('/');
}
res.json({ user: req.session.user });
});
Using Passport.js
For simpler setup, use Passport.js with provider strategies:
// server-passport.js
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const GitHubStrategy = require('passport-github2').Strategy;
const app = express();
// Session configuration
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false
}));
app.use(passport.initialize());
app.use(passport.session());
// Serialize user to session
passport.serializeUser((user, done) => {
done(null, user.id);
});
// Deserialize user from session
passport.deserializeUser(async (id, done) => {
// Look up user in your database
const user = await findUserById(id);
done(null, user);
});
// Google Strategy
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback'
},
(accessToken, refreshToken, profile, done) => {
// Find or create user in your database
User.findOrCreate({ providerId: profile.id, provider: 'google' }, {
email: profile.emails[0].value,
name: profile.displayName,
picture: profile.photos[0]?.value
})
.then((user) => done(null, user))
.catch((err) => done(err));
}
));
// GitHub Strategy
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/auth/github/callback'
},
(accessToken, refreshToken, profile, done) => {
User.findOrCreate({ providerId: profile.id, provider: 'github' }, {
email: profile.emails?.[0]?.value,
name: profile.displayName || profile.username,
picture: profile.photos[0]?.value
})
.then((user) => done(null, user))
.catch((err) => done(err));
}
));
// Auth routes
app.get('/auth/google', passport.authenticate('google', {
scope: ['profile', 'email']
}));
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => res.redirect('/dashboard')
);
app.get('/auth/github', passport.authenticate('github', {
scope: ['user:email']
}));
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => res.redirect('/dashboard')
);
Client-Side Implementation
React OAuth Implementation
// AuthProvider.jsx
import React, { createContext, useContext, useState, useEffect } from 'react';
const AuthContext = createContext(null);
const OAUTH_CONFIG = {
google: {
clientId: process.env.REACT_APP_GOOGLE_CLIENT_ID,
redirectUri: `${window.location.origin}/oauth/callback`,
scope: 'openid email profile',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth'
},
github: {
clientId: process.env.REACT_APP_GITHUB_CLIENT_ID,
redirectUri: `${window.location.origin}/oauth/callback`,
scope: 'read:user user:email',
authorizationUrl: 'https://github.com/login/oauth/authorize'
}
};
// PKCE helpers
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
async function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(hash);
}
function generateState() {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check for existing session on mount
checkAuth();
}, []);
const checkAuth = async () => {
try {
const response = await fetch('/api/auth/me', {
credentials: 'include'
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch (error) {
console.error('Auth check failed:', error);
} finally {
setLoading(false);
}
};
const loginWithGoogle = async () => {
const codeVerifier = await generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateState();
// Store PKCE values for callback
sessionStorage.setItem('oauth_code_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('oauth_provider', 'google');
const config = OAUTH_CONFIG.google;
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.redirectUri,
response_type: 'code',
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: state
});
window.location.href = `${config.authorizationUrl}?${params}`;
};
const loginWithGithub = () => {
const state = generateState();
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('oauth_provider', 'github');
const config = OAUTH_CONFIG.github;
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scope,
state: state
});
window.location.href = `${config.authorizationUrl}?${params}`;
};
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, loginWithGoogle, loginWithGithub, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
OAuth Callback Handler
// OAuthCallback.jsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
export function OAuthCallback() {
const navigate = useNavigate();
useEffect(() => {
handleCallback();
}, []);
const handleCallback = async () => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
if (error) {
console.error('OAuth error:', error);
navigate('/login?error=oauth_failed');
return;
}
if (!code) {
navigate('/login?error=no_code');
return;
}
// Verify state
const storedState = sessionStorage.getItem('oauth_state');
const provider = sessionStorage.getItem('oauth_provider');
if (state !== storedState) {
navigate('/login?error=invalid_state');
return;
}
try {
// Exchange code for session
const response = await fetch('/api/auth/oauth/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
provider,
codeVerifier: sessionStorage.getItem('oauth_code_verifier')
}),
credentials: 'include'
});
if (!response.ok) {
throw new Error('Authentication failed');
}
// Clean up storage
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_code_verifier');
sessionStorage.removeItem('oauth_provider');
navigate('/dashboard');
} catch (error) {
console.error('Callback error:', error);
navigate('/login?error=authentication_failed');
}
};
return <div>Authenticating...</div>;
}
Login Component
// LoginPage.jsx
import { useAuth } from './AuthProvider';
export function LoginPage() {
const { loginWithGoogle, loginWithGithub } = useAuth();
return (
<div className="login-page">
<h1>Welcome</h1>
<p>Sign in to continue</p>
<div className="oauth-buttons">
<button onClick={loginWithGoogle} className="btn-google">
<img src="/google-icon.svg" alt="Google" />
Continue with Google
</button>
<button onClick={loginWithGithub} className="btn-github">
<img src="/github-icon.svg" alt="GitHub" />
Continue with GitHub
</button>
</div>
<div className="divider">
<span>or</span>
</div>
<form className="email-login">
<input type="email" placeholder="Email" />
<button type="submit">Continue with Email</button>
</form>
</div>
);
}
Token Management
Token Refresh
// tokenManager.js
class TokenManager {
constructor(axiosInstance) {
this.axios = axiosInstance;
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
}
setTokens(accessToken, refreshToken, expiresIn) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.tokenExpiry = Date.now() + expiresIn * 1000;
}
async getValidToken() {
// Return current token if still valid
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
return this.accessToken;
}
// Refresh token if available
if (this.refreshToken) {
try {
const response = await this.axios.post('/api/auth/refresh', {
refresh_token: this.refreshToken
});
this.setTokens(
response.data.access_token,
response.data.refresh_token,
response.data.expires_in
);
return this.accessToken;
} catch (error) {
// Refresh failed, need to re-authenticate
this.clearTokens();
throw new Error('Session expired');
}
}
throw new Error('No valid token');
}
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
}
}
// Axios interceptor for automatic token handling
function setupAxiosInterceptors(axiosInstance, tokenManager) {
axiosInstance.interceptors.request.use(
async (config) => {
const token = await tokenManager.getValidToken();
config.headers.Authorization = `Bearer ${token}`;
return config;
},
(error) => Promise.reject(error)
);
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Try to refresh on 401
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const token = await tokenManager.getValidToken();
originalRequest.headers.Authorization = `Bearer ${token`;
return axiosInstance(originalRequest);
} catch (refreshError) {
// Redirect to login
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
}
Secure Token Storage
// Frontend: Never store tokens in localStorage
// Use httpOnly cookies instead
// Server: Set tokens as httpOnly cookies
app.post('/auth/oauth/callback', async (req, res) => {
const { access_token, refresh_token } = await exchangeCodeForTokens(req.body);
// Set httpOnly cookies
res.cookie('access_token', access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 1000 // 1 hour
});
res.cookie('refresh_token', refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({ success: true });
});
// Refresh endpoint
app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
const newTokens = await refreshAccessToken(refreshToken);
res.cookie('access_token', newTokens.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 1000
});
res.json({
access_token: newTokens.access_token,
expires_in: 3600
});
});
Provider-Specific Setup
Google OAuth Setup
- Go to Google Cloud Console
- Create a new project
- Navigate to APIs & Services > Credentials
- Create OAuth 2.0 Client ID
- Configure authorized redirect URIs
- Get Client ID and Client Secret
// Environment variables
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-secret
GitHub OAuth Setup
- Go to GitHub Settings > Developer settings > OAuth Apps
- Click “New OAuth App”
- Fill in application details
- Get Client ID and generate Client Secret
// Environment variables
GITHUB_CLIENT_ID=Iv1.xxx
GITHUB_CLIENT_SECRET=xxx
Facebook OAuth Setup
app.get('/auth/facebook', (req, res) => {
const facebookConfig = {
clientId: process.env.FACEBOOK_CLIENT_ID,
redirectUri: 'http://localhost:3000/auth/facebook/callback',
scope: 'email,public_profile'
};
const params = new URLSearchParams({
client_id: facebookConfig.clientId,
redirect_uri: facebookConfig.redirectUri,
scope: facebookConfig.scope,
response_type: 'code',
state: generateState()
});
res.redirect(`https://www.facebook.com/v18.0/dialog/oauth?${params}`);
});
Security Best Practices
Essential Security Measures
const securityConfig = {
// 1. Use PKCE for all OAuth flows
// Already implemented in code above
// 2. Validate state parameter
function validateState(req, providedState) {
const storedState = req.session.oauthState;
if (!storedState || storedState !== providedState) {
throw new Error('Invalid state - possible CSRF attack');
}
}
// 3. Use short-lived tokens
tokenConfig: {
accessTokenExpiry: 3600, // 1 hour
refreshTokenExpiry: 604800, // 7 days
idTokenExpiry: 3600 // 1 hour
},
// 4. Implement token rotation
async rotateRefreshToken(oldRefreshToken) {
const response = await axios.post(tokenUrl, {
grant_type: 'refresh_token',
refresh_token: oldRefreshToken,
client_id: clientId
});
// New refresh token should be used
return response.data;
},
// 5. Use httpOnly cookies for tokens
cookieOptions: {
httpOnly: true,
secure: true, // HTTPS only in production
sameSite: 'strict',
path: '/'
}
};
// Rate limiting for OAuth endpoints
const rateLimit = require('express-rate-limit');
const oauthLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20, // 20 attempts per window
message: 'Too many OAuth attempts, please try again later'
});
app.use('/auth/', oauthLimiter);
Token Validation
// Server: Validate ID token with Google
const { OAuth2Client } = require('google-auth-library');
const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
async function verifyGoogleIdToken(idToken) {
try {
const ticket = await client.verifyIdToken({
idToken: idToken,
audience: process.env.GOOGLE_CLIENT_ID
});
const payload = ticket.getPayload();
return {
valid: true,
user: {
id: payload.sub,
email: payload.email,
name: payload.name,
picture: payload.picture
}
};
} catch (error) {
return { valid: false, error: error.message };
}
}
// Verify access token
async function verifyGoogleAccessToken(accessToken) {
try {
const response = await axios.get(
'https://www.googleapis.com/oauth2/v3/tokeninfo',
{ params: { access_token: accessToken } }
);
return response.data;
} catch (error) {
return null;
}
}
Handling Edge Cases
Error Handling
// OAuth error types
const OAUTH_ERRORS = {
// Authorization errors
access_denied: 'User denied access',
invalid_request: 'Malformed request',
unauthorized_client: 'Client not authorized',
unsupported_response_type: 'Response type not supported',
invalid_scope: 'Invalid scope requested',
server_error: 'Authorization server error',
temporarily_unavailable: 'Server temporarily unavailable',
// Token errors
invalid_grant: 'Invalid or expired authorization code',
invalid_client: 'Invalid client credentials',
invalid_token: 'Invalid or expired token',
unsupported_grant_type: 'Grant type not supported'
};
app.get('/auth/:provider/callback', (req, res) => {
const { error, error_description, code, state } = req.query;
if (error) {
const errorMessage = OAUTH_ERRORS[error] || error_description || 'Authentication failed';
return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}`);
}
if (!code) {
return res.redirect('/login?error=no_authorization_code');
}
// Continue with token exchange...
});
Account Linking
// Link OAuth account to existing user
async function linkOAuthAccount(userId, provider, providerId, email) {
const existingOAuth = await OAuthAccount.findOne({ provider, providerId });
if (existingOAuth) {
if (existingOAuth.userId.toString() !== userId) {
throw new Error('This OAuth account is already linked to another user');
}
// Already linked to current user
return existingOAuth;
}
// Create new link
return OAuthAccount.create({
userId,
provider,
providerId,
email,
linkedAt: new Date()
});
}
// Handle login with account that has multiple OAuth providers
async function findOrCreateUser(profile, provider) {
// Check if email exists with different provider
const existingEmail = await User.findOne({
email: profile.email,
_id: { $ne: profile._id }
});
if (existingEmail) {
// Prompt user to link accounts
return { needsLinking: true, existingUser: existingEmail, newProfile: profile };
}
// Check if OAuth account exists
const existingOAuth = await OAuthAccount.findOne({
provider,
providerId: profile.providerId
});
if (existingOAuth) {
// Return existing user
return { user: await User.findById(existingOAuth.userId) };
}
// Create new user
const newUser = await User.create({
email: profile.email,
name: profile.name,
picture: profile.picture,
verified: true // OAuth providers verify email
});
await OAuthAccount.create({
userId: newUser._id,
provider,
providerId: profile.providerId
});
return { user: newUser };
}
Conclusion
OAuth implementation requires attention to security details:
- Always use PKCE - Protects against authorization code interception
- Validate state parameter - Prevents CSRF attacks
- Use httpOnly cookies - Never store tokens in localStorage
- Implement token refresh - Maintains seamless user experience
- Handle errors gracefully - Provide clear feedback to users
- Link accounts carefully - Handle multiple OAuth providers for same email
For production deployments, consider using established libraries like:
- Auth0 - Full-featured identity platform
- Clerk - Modern authentication for React apps
- NextAuth.js - Authentication for Next.js applications
- Supabase Auth - Open-source Firebase alternative
Comments