How to Implement OAuth in Your Web Application
Understanding OAuth 2.0
Hello everyone! I'm Shailesh Chaudhari, a full-stack developer who has implemented authentication systems for various web applications. Today, I'll guide you through implementing OAuth 2.0 authentication in your web applications.
OAuth 2.0 is the industry-standard protocol for authorization, allowing users to grant third-party applications access to their resources without sharing their credentials. It's commonly used for "Login with Google/Facebook/GitHub" functionality.
OAuth 2.0 Roles
- Resource Owner: The user who owns the data
- Client: Your application requesting access
- Authorization Server: Issues access tokens (e.g., Google)
- Resource Server: Hosts the protected resources
OAuth 2.0 Flow Types
Authorization Code Flow (Most Secure)
The authorization code flow is the most secure and recommended for web applications:
- User clicks "Login with Google"
- App redirects to Google's authorization endpoint
- User grants permission
- Google redirects back with authorization code
- App exchanges code for access token
- App uses token to access user data
Implicit Flow (Legacy)
Used for client-side applications, but less secure than authorization code flow.
Backend Setup (Node.js/Express)
Installing Dependencies
npm install express passport passport-google-oauth20 passport-github2 passport-facebook express-session
npm install --save-dev @types/passport @types/express-session
Passport Configuration
// lib/auth.ts
import passport from 'passport'
import { Strategy as GoogleStrategy } from 'passport-google-oauth20'
import { Strategy as GitHubStrategy } from 'passport-github2'
import { Strategy as FacebookStrategy } from 'passport-facebook'
// Serialize user for session
passport.serializeUser((user: any, done) => {
done(null, user.id)
})
passport.deserializeUser(async (id: string, done) => {
try {
// Fetch user from database
const user = await User.findById(id)
done(null, user)
} catch (error) {
done(error, null)
}
})
// Google Strategy
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
callbackURL: '/auth/google/callback'
}, async (accessToken, refreshToken, profile, done) => {
try {
// Find or create user
let user = await User.findOne({ googleId: profile.id })
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails?.[0].value,
name: profile.displayName,
avatar: profile.photos?.[0].value,
provider: 'google'
})
}
done(null, user)
} catch (error) {
done(error, null)
}
}))
// GitHub Strategy
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
callbackURL: '/auth/github/callback'
}, async (accessToken, refreshToken, profile, done) => {
try {
let user = await User.findOne({ githubId: profile.id })
if (!user) {
user = await User.create({
githubId: profile.id,
email: profile.emails?.[0].value,
name: profile.displayName,
avatar: profile.photos?.[0].value,
provider: 'github'
})
}
done(null, user)
} catch (error) {
done(error, null)
}
}))
// Facebook Strategy
passport.use(new FacebookStrategy({
clientID: process.env.FACEBOOK_APP_ID!,
clientSecret: process.env.FACEBOOK_APP_SECRET!,
callbackURL: '/auth/facebook/callback',
profileFields: ['id', 'emails', 'name', 'picture']
}, async (accessToken, refreshToken, profile, done) => {
try {
let user = await User.findOne({ facebookId: profile.id })
if (!user) {
user = await User.create({
facebookId: profile.id,
email: profile.emails?.[0].value,
name: profile.displayName,
avatar: profile.photos?.[0].value,
provider: 'facebook'
})
}
done(null, user)
} catch (error) {
done(error, null)
}
}))
export default passport
Express Server Setup
// server.ts
import express from 'express'
import session from 'express-session'
import passport from './lib/auth'
const app = express()
// Session configuration
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}))
// Passport middleware
app.use(passport.initialize())
app.use(passport.session())
// 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')
}
)
app.get('/auth/facebook',
passport.authenticate('facebook', { scope: ['email'] })
)
app.get('/auth/facebook/callback',
passport.authenticate('facebook', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard')
}
)
// Logout
app.post('/auth/logout', (req, res) => {
req.logout((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' })
}
res.redirect('/')
})
})
// Get current user
app.get('/auth/user', (req, res) => {
if (req.user) {
res.json({ user: req.user })
} else {
res.status(401).json({ error: 'Not authenticated' })
}
})
const PORT = process.env.PORT || 3001
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
Frontend Implementation (Next.js)
OAuth Login Component
// components/OAuthLogin.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
interface OAuthLoginProps {
onSuccess?: () => void
onError?: (error: string) => void
}
export default function OAuthLogin({ onSuccess, onError }: OAuthLoginProps) {
const [loading, setLoading] = useState<string | null>(null)
const router = useRouter()
const handleOAuthLogin = async (provider: string) => {
setLoading(provider)
try {
// For Next.js API routes
window.location.href = `/api/auth/${provider}`
// Alternative: Open popup for better UX
// const popup = window.open(
// `/api/auth/${provider}`,
// 'oauth-popup',
// 'width=500,height=600'
// )
// if (popup) {
// const checkClosed = setInterval(() => {
// if (popup.closed) {
// clearInterval(checkClosed)
// // Check authentication status
// router.refresh()
// onSuccess?.()
// }
// }, 1000)
// }
} catch (error) {
setLoading(null)
onError?.('Authentication failed')
}
}
return (
<div className="space-y-4">
<button
onClick={() => handleOAuthLogin('google')}
disabled={!!loading}
className="w-full flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
{loading === 'google' ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-600 mr-2"></div>
) : (
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
)}
Continue with Google
</button>
<button
onClick={() => handleOAuthLogin('github')}
disabled={!!loading}
className="w-full flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
{loading === 'github' ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
) : (
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
)}
Continue with GitHub
</button>
<button
onClick={() => handleOAuthLogin('facebook')}
disabled={!!loading}
className="w-full flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
{loading === 'facebook' ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
) : (
<svg className="w-5 h-5 mr-2" fill="#1877F2" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
)}
Continue with Facebook
</button>
</div>
)
}
Next.js API Routes
// pages/api/auth/[...nextauth].ts (NextAuth.js alternative)
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import GitHubProvider from 'next-auth/providers/github'
import FacebookProvider from 'next-auth/providers/facebook'
export default NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
FacebookProvider({
clientId: process.env.FACEBOOK_APP_ID!,
clientSecret: process.env.FACEBOOK_APP_SECRET!,
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.accessToken = account.access_token
}
return token
},
async session({ session, token }) {
session.accessToken = token.accessToken
return session
},
},
pages: {
signIn: '/auth/signin',
error: '/auth/error',
},
})
JWT Token Management
Custom JWT Implementation
// lib/jwt.ts
import jwt from 'jsonwebtoken'
const JWT_SECRET = process.env.JWT_SECRET!
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!
export interface JWTPayload {
userId: string
email: string
provider: string
iat?: number
exp?: number
}
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' })
}
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return jwt.sign(payload, JWT_REFRESH_SECRET, { expiresIn: '7d' })
}
export function verifyAccessToken(token: string): JWTPayload | null {
try {
return jwt.verify(token, JWT_SECRET) as JWTPayload
} catch (error) {
return null
}
}
export function verifyRefreshToken(token: string): JWTPayload | null {
try {
return jwt.verify(token, JWT_REFRESH_SECRET) as JWTPayload
} catch (error) {
return null
}
}
// Refresh token rotation
export async function refreshAccessToken(refreshToken: string) {
const payload = verifyRefreshToken(refreshToken)
if (!payload) {
throw new Error('Invalid refresh token')
}
// Generate new tokens
const newAccessToken = generateAccessToken({
userId: payload.userId,
email: payload.email,
provider: payload.provider,
})
const newRefreshToken = generateRefreshToken({
userId: payload.userId,
email: payload.email,
provider: payload.provider,
})
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
}
}
Token Storage and Management
// lib/auth-storage.ts
export class AuthStorage {
private static ACCESS_TOKEN_KEY = 'access_token'
private static REFRESH_TOKEN_KEY = 'refresh_token'
static setTokens(accessToken: string, refreshToken: string) {
if (typeof window !== 'undefined') {
localStorage.setItem(this.ACCESS_TOKEN_KEY, accessToken)
localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken)
}
}
static getAccessToken(): string | null {
if (typeof window !== 'undefined') {
return localStorage.getItem(this.ACCESS_TOKEN_KEY)
}
return null
}
static getRefreshToken(): string | null {
if (typeof window !== 'undefined') {
return localStorage.getItem(this.REFRESH_TOKEN_KEY)
}
return null
}
static clearTokens() {
if (typeof window !== 'undefined') {
localStorage.removeItem(this.ACCESS_TOKEN_KEY)
localStorage.removeItem(this.REFRESH_TOKEN_KEY)
}
}
static isAuthenticated(): boolean {
const token = this.getAccessToken()
if (!token) return false
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.exp * 1000 > Date.now()
} catch {
return false
}
}
}
Security Best Practices
State Parameter Protection
// Prevent CSRF attacks
app.get('/auth/google', (req, res) => {
const state = crypto.randomBytes(32).toString('hex')
req.session.oauthState = state
passport.authenticate('google', {
scope: ['profile', 'email'],
state: state
})(req, res)
})
app.get('/auth/google/callback', (req, res) => {
if (req.query.state !== req.session.oauthState) {
return res.status(403).json({ error: 'Invalid state parameter' })
}
passport.authenticate('google', { failureRedirect: '/login' })(req, res)
})
PKCE for Public Clients
// Proof Key for Code Exchange
function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url')
}
function generateCodeChallenge(verifier: string) {
const hash = crypto.createHash('sha256').update(verifier).digest()
return hash.toString('base64url')
}
// Usage in authorization request
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
// Store codeVerifier in session
req.session.codeVerifier = codeVerifier
// Include in authorization URL
const authUrl = `https://accounts.google.com/oauth/authorize?...${codeChallenge}...`
Secure Cookie Configuration
// Session configuration for production
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only
httpOnly: true, // Prevent XSS
sameSite: 'strict', // CSRF protection
maxAge: 24 * 60 * 60 * 1000 // 24 hours
},
store: new RedisStore({ // Use Redis in production
client: redisClient,
ttl: 24 * 60 * 60
})
}))
OAuth Provider Setup
Google OAuth Setup
- Go to Google Cloud Console
- Create a new project or select existing one
- Enable Google+ API
- Create OAuth 2.0 credentials
- Add authorized redirect URIs
- Configure consent screen
GitHub OAuth Setup
- Go to GitHub Settings → Developer settings → OAuth Apps
- Click "New OAuth App"
- Fill in application details
- Add authorization callback URL
- Copy Client ID and Client Secret
Facebook OAuth Setup
- Go to Facebook Developers
- Create a new app
- Add Facebook Login product
- Configure OAuth redirect URIs
- Get App ID and App Secret
Handling OAuth Errors
Error Types and Solutions
// OAuth error handling middleware
app.use('/auth', (error, req, res, next) => {
if (error.code === 'access_denied') {
return res.redirect('/login?error=access_denied')
}
if (error.code === 'invalid_request') {
return res.redirect('/login?error=invalid_request')
}
if (error.code === 'unauthorized_client') {
return res.redirect('/login?error=unauthorized_client')
}
// Log error and redirect to generic error page
console.error('OAuth error:', error)
res.redirect('/login?error=oauth_error')
})
Frontend Error Handling
// components/AuthError.tsx
'use client'
import { useSearchParams } from 'next/navigation'
const errorMessages = {
access_denied: 'Access was denied by the OAuth provider.',
invalid_request: 'Invalid authorization request.',
unauthorized_client: 'Unauthorized client application.',
oauth_error: 'An error occurred during authentication.',
}
export default function AuthError() {
const searchParams = useSearchParams()
const error = searchParams.get('error')
if (!error || !errorMessages[error as keyof typeof errorMessages]) {
return null
}
return (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
Authentication Error
</h3>
<div className="mt-2 text-sm text-red-700">
{errorMessages[error as keyof typeof errorMessages]}
</div>
</div>
</div>
</div>
)
}
Testing OAuth Implementation
Development Environment Setup
# .env.local
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
FACEBOOK_APP_ID=your_facebook_app_id
FACEBOOK_APP_SECRET=your_facebook_app_secret
SESSION_SECRET=your_session_secret
JWT_SECRET=your_jwt_secret
JWT_REFRESH_SECRET=your_jwt_refresh_secret
Testing Different Scenarios
- Successful authentication: Complete OAuth flow
- User denies access: Handle access_denied error
- Invalid credentials: Test with wrong client secrets
- Token expiration: Test refresh token flow
- Network errors: Simulate connection issues
- State parameter mismatch: Test CSRF protection
Production Deployment
Environment Variables
# Production environment variables
NODE_ENV=production
SESSION_SECRET=strong_random_secret_here
JWT_SECRET=another_strong_secret
JWT_REFRESH_SECRET=yet_another_secret
# OAuth credentials (use production apps)
GOOGLE_CLIENT_ID=prod_google_client_id
GOOGLE_CLIENT_SECRET=prod_google_client_secret
# ... other provider credentials
Security Checklist
- ✅ Use HTTPS in production
- ✅ Store secrets securely (not in code)
- ✅ Implement proper session management
- ✅ Use secure cookies
- ✅ Validate redirect URIs
- ✅ Implement rate limiting
- ✅ Log authentication events
- ✅ Regular security audits
Advanced OAuth Features
Scope Management
// Request specific permissions
app.get('/auth/google/calendar',
passport.authenticate('google', {
scope: [
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/userinfo.email'
]
})
)
// Handle incremental authorization
app.get('/auth/google/incremental', (req, res) => {
const scopes = req.query.scopes?.split(',') || ['profile', 'email']
passport.authenticate('google', {
scope: scopes,
accessType: 'offline', // For refresh tokens
prompt: 'consent' // Force consent screen
})(req, res)
})
Multi-Provider Account Linking
// Link multiple OAuth accounts to one user
app.post('/auth/link-provider', async (req, res) => {
const { provider, code } = req.body
const userId = req.user.id
try {
// Exchange code for token and get user info
const tokenData = await exchangeCodeForToken(provider, code)
const providerUser = await getProviderUserInfo(provider, tokenData.access_token)
// Link to existing user account
await User.findByIdAndUpdate(userId, {
$set: {
[`${provider}Id`]: providerUser.id,
[`${provider}Profile`]: providerUser
}
})
res.json({ success: true, linked: provider })
} catch (error) {
res.status(500).json({ error: 'Failed to link account' })
}
})
Conclusion: Building Secure OAuth Systems
Implementing OAuth 2.0 authentication in your web application doesn't have to be complex. By following the patterns and best practices outlined in this guide, you can build secure, scalable authentication systems that integrate seamlessly with major OAuth providers.
Start with the fundamentals—choose the right OAuth flow for your application, implement proper security measures, and handle errors gracefully. As your application grows, you can add advanced features like account linking, incremental authorization, and custom scopes.
Remember that authentication is a critical security component of your application. Always prioritize security best practices, keep your dependencies updated, and regularly audit your implementation for vulnerabilities.
The key to successful OAuth implementation is understanding the protocol, implementing proper security measures, and providing a smooth user experience. With these foundations in place, you can confidently handle user authentication at any scale.
"Authentication is not just about security—it's about building trust with your users." - Shailesh Chaudhari