All articles
Authentication Security Backend Node.js

OAuth 2.0 Authentication Authentication Strategies for Modern Web Applications

Palakorn Voramongkol
April 11, 2025 13 min read

“A comprehensive implementation guide to OAuth 2.0 — covering authorization flows, OpenID Connect, implementing Google and GitHub login, token management, and enterprise SSO patterns.”

Deep Dive: OAuth 2.0 Authentication

What is OAuth 2.0?

OAuth 2.0 is an authorization framework (RFC 6749) that allows a third-party application to access a user’s resources on another service — without the user sharing their password. When you click “Sign in with Google” or “Connect your GitHub account,” OAuth 2.0 is the protocol orchestrating that handshake behind the scenes.

The key insight: OAuth separates the concepts of authentication (who is this user?) and authorization (what can this app do on the user’s behalf?). OAuth 2.0 itself only handles authorization. OpenID Connect (OIDC), built on top of OAuth 2.0, adds the authentication layer by introducing the ID token — a JWT containing verified user identity claims.

OAuth 2.0 was published in 2012 as a replacement for OAuth 1.0, which required complex request signing. The simplified model uses bearer tokens and HTTPS, making integration significantly easier while maintaining security when implemented correctly.

Core Principles

  • Delegated access: Users grant limited, scoped permissions to applications without sharing credentials.
  • Authorization server as intermediary: A trusted third party (Google, GitHub, Azure AD) handles the actual authentication and consent.
  • Token-based: After the authorization dance, the app receives tokens (access, refresh, and optionally ID) to interact with APIs.
  • Scoped permissions: Applications request only the access they need (e.g., read:email, repo:read).

Authorization Code Flow (with PKCE)

This is the most common and most secure flow, used by web apps, mobile apps, and SPAs.

sequenceDiagram
    participant U as User
    participant App as Your Application
    participant AS as Authorization Server (Google/GitHub)
    participant API as Resource API

    U->>App: Click "Sign in with Google"
    App->>App: Generate code_verifier + code_challenge (PKCE)
    App->>AS: Redirect to /authorize?response_type=code&client_id=...&code_challenge=...
    AS->>U: Show login + consent screen
    U->>AS: Enter credentials, approve scopes
    AS->>App: Redirect to callback with ?code=AUTH_CODE
    App->>AS: POST /token {code, client_secret, code_verifier}
    AS-->>App: {access_token, refresh_token, id_token}
    App->>API: GET /userinfo (Authorization: Bearer <access_token>)
    API-->>App: {sub, email, name, picture}
    App->>App: Create/find user, issue session or JWT
    App-->>U: Logged in — redirect to dashboard

Now let’s explore the distinction between OAuth 2.0 and OpenID Connect in detail.

OAuth 2.0 vs OpenID Connect: The Critical Distinction

Many developers confuse OAuth 2.0 with OpenID Connect (OIDC). They’re related but solve different problems:

OAuth 2.0 is an authorization framework. It answers: “What can this application access on behalf of the user?” OAuth is primarily about delegating access to resources—like reading GitHub repositories or Google Drive files.

OpenID Connect is an authentication layer built on top of OAuth 2.0. It adds identity information. It answers: “Who is this user?” OIDC introduces the ID token (a JWT) containing user identity claims, enabling social login scenarios.

In Practice

  • Use OAuth 2.0 alone when you need API access: integrating with Stripe, accessing Google Drive, reading GitHub repos
  • Use OpenID Connect when implementing “Sign in with Google” or “Sign in with GitHub”—you care about identifying the user
  • Use both in typical scenarios: “Sign in with GitHub” + “allow this app to read your repos”

Authorization Code Flow with PKCE

The Authorization Code Flow is the most secure flow for web applications and mobile apps. PKCE (Proof Key for Code Exchange) adds additional security, especially for mobile and SPA applications.

Authorization Code Flow (with PKCE) - Step by Step

User → Your App → Authorization Server → Your App → Authorization Server → Your App
  ↓        ↓              ↓                   ↓               ↓                ↓
Click    Redirect    User logs in      Exchanges code   Returns tokens    Logged in
Login    to provider & consents       for access token   (access, refresh)

Step 1: Generate Challenge Your application generates a random string (code verifier) and creates a challenge from it:

import crypto from 'crypto';

function generateCodeChallenge(codeVerifier: string): string {
  return crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

const codeVerifier = crypto.randomBytes(32).toString('hex');
const codeChallenge = generateCodeChallenge(codeVerifier);
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;

public class PKCEUtil {
    public static String generateCodeVerifier() {
        SecureRandom sr = new SecureRandom();
        byte[] code = new byte[32];
        sr.nextBytes(code);
        return bytesToHex(code);
    }

    public static String generateCodeChallenge(String codeVerifier) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(codeVerifier.getBytes("UTF-8"));
        return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) sb.append(String.format("%02x", b));
        return sb.toString();
    }
}
import hashlib
import os
import base64

def generate_code_verifier() -> str:
    return os.urandom(32).hex()

def generate_code_challenge(code_verifier: str) -> str:
    digest = hashlib.sha256(code_verifier.encode()).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b'=').decode()

code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
using System.Security.Cryptography;
using System.Text;

public static class PKCEUtil
{
    public static string GenerateCodeVerifier()
    {
        var bytes = RandomNumberGenerator.GetBytes(32);
        return Convert.ToHexString(bytes).ToLower();
    }

    public static string GenerateCodeChallenge(string codeVerifier)
    {
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
        return Base64UrlEncode(hash);
    }

    private static string Base64UrlEncode(byte[] bytes) =>
        Convert.ToBase64String(bytes)
            .Replace('+', '-')
            .Replace('/', '_')
            .TrimEnd('=');
}

Step 2: Redirect to Authorization Server

https://provider.com/authorize?
  client_id=YOUR_CLIENT_ID&
  redirect_uri=https://yourapp.com/callback&
  response_type=code&
  scope=openid%20profile%20email&
  state=random_state_value&
  code_challenge=CHALLENGE&
  code_challenge_method=S256

Step 3: Authorization Server Redirects Back After user approval:

https://yourapp.com/callback?
  code=AUTH_CODE&
  state=random_state_value

Step 4: Exchange Code for Tokens Your backend exchanges the code (proving the code verifier) for tokens:

const response = await fetch('https://provider.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authCode,
    client_id: YOUR_CLIENT_ID,
    client_secret: YOUR_CLIENT_SECRET,
    redirect_uri: 'https://yourapp.com/callback',
    code_verifier: codeVerifier, // Proves we own the challenge
  }),
});

const tokens = await response.json();
import org.springframework.http.*;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

public Map<String, Object> exchangeCodeForTokens(
        String authCode, String codeVerifier) {
    RestTemplate rest = new RestTemplate();

    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
    body.add("grant_type", "authorization_code");
    body.add("code", authCode);
    body.add("client_id", YOUR_CLIENT_ID);
    body.add("client_secret", YOUR_CLIENT_SECRET);
    body.add("redirect_uri", "https://yourapp.com/callback");
    body.add("code_verifier", codeVerifier);

    HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
    ResponseEntity<Map> response = rest.postForEntity(
        "https://provider.com/token", request, Map.class);
    return response.getBody();
}
import httpx

async def exchange_code_for_tokens(auth_code: str, code_verifier: str) -> dict:
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://provider.com/token",
            data={
                "grant_type": "authorization_code",
                "code": auth_code,
                "client_id": YOUR_CLIENT_ID,
                "client_secret": YOUR_CLIENT_SECRET,
                "redirect_uri": "https://yourapp.com/callback",
                "code_verifier": code_verifier,
            },
        )
        response.raise_for_status()
        return response.json()
using System.Net.Http;
using System.Collections.Generic;
using System.Text.Json;

public async Task<JsonDocument> ExchangeCodeForTokensAsync(
    string authCode, string codeVerifier)
{
    using var client = new HttpClient();
    var content = new FormUrlEncodedContent(new Dictionary<string, string>
    {
        ["grant_type"] = "authorization_code",
        ["code"] = authCode,
        ["client_id"] = YOUR_CLIENT_ID,
        ["client_secret"] = YOUR_CLIENT_SECRET,
        ["redirect_uri"] = "https://yourapp.com/callback",
        ["code_verifier"] = codeVerifier,
    });

    var response = await client.PostAsync("https://provider.com/token", content);
    response.EnsureSuccessStatusCode();
    var json = await response.Content.ReadAsStringAsync();
    return JsonDocument.Parse(json);
}

Other Grant Types: A Quick Reference

Client Credentials Flow

For server-to-server authentication when there’s no user involved:

const response = await fetch('https://provider.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: YOUR_CLIENT_ID,
    client_secret: YOUR_CLIENT_SECRET,
    scope: 'api.read api.write',
  }),
});
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "client_credentials");
body.add("client_id", YOUR_CLIENT_ID);
body.add("client_secret", YOUR_CLIENT_SECRET);
body.add("scope", "api.read api.write");

HttpEntity<MultiValueMap<String, String>> request =
    new HttpEntity<>(body, headers);
ResponseEntity<Map> response = rest.postForEntity(
    "https://provider.com/token", request, Map.class);
async with httpx.AsyncClient() as client:
    response = await client.post(
        "https://provider.com/token",
        data={
            "grant_type": "client_credentials",
            "client_id": YOUR_CLIENT_ID,
            "client_secret": YOUR_CLIENT_SECRET,
            "scope": "api.read api.write",
        },
    )
    response.raise_for_status()
    tokens = response.json()
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
    ["grant_type"] = "client_credentials",
    ["client_id"] = YOUR_CLIENT_ID,
    ["client_secret"] = YOUR_CLIENT_SECRET,
    ["scope"] = "api.read api.write",
});
var response = await client.PostAsync("https://provider.com/token", content);
response.EnsureSuccessStatusCode();

Use cases: CI/CD pipelines accessing APIs, backend services talking to each other, scheduled jobs.

Device Code Flow

For devices without browsers (smart TVs, IoT devices, CLIs):

The device displays a code to the user, who enters it on another device. The device polls for authorization completion.

Implicit Flow (Deprecated)

Legacy flow returning tokens directly in the URL. Don’t use this—it’s vulnerable to token exposure in browser history and referer headers. The Authorization Code Flow with PKCE is now the standard for SPAs.

Implementing Google OAuth with Passport.js

Here’s a complete, production-ready implementation using Passport.js:

Installation

npm install passport passport-google-oauth20 express-session
npm install -D @types/passport-google-oauth20

Configuration

// src/config/passport.ts
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { User } from '@/models/user';
import { findOrCreateUser } from '@/services/auth';

passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      callbackURL: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/auth/google/callback',
      scope: ['profile', 'email'],
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        // Find or create user
        const user = await findOrCreateUser({
          provider: 'google',
          providerId: profile.id,
          email: profile.emails?.[0]?.value,
          name: profile.displayName,
          picture: profile.photos?.[0]?.value,
          accessToken,
          refreshToken,
        });

        done(null, user);
      } catch (error) {
        done(error);
      }
    }
  )
);

// Serialize user for session
passport.serializeUser((user: any, done) => {
  done(null, user.id);
});

passport.deserializeUser(async (id: string, done) => {
  try {
    const user = await User.findById(id);
    done(null, user);
  } catch (error) {
    done(error);
  }
});

export default passport;
// Spring Boot — application.properties + OAuth2 auto-configuration
// application.properties:
// spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
// spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
// spring.security.oauth2.client.registration.google.scope=profile,email

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private CustomOAuth2UserService oAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(info -> info
                    .userService(oAuth2UserService))
                .successHandler(this::onAuthSuccess)
                .failureUrl("/login?error=true"))
            .logout(logout -> logout
                .logoutUrl("/auth/logout")
                .deleteCookies("JSESSIONID"));
        return http.build();
    }

    private void onAuthSuccess(HttpServletRequest req,
            HttpServletResponse res, Authentication auth) throws IOException {
        // Issue session token, redirect to dashboard
        res.sendRedirect("/dashboard");
    }
}

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    @Autowired private UserRepository userRepo;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest req) {
        OAuth2User oauthUser = super.loadUser(req);
        String provider = req.getClientRegistration().getRegistrationId();
        String providerId = oauthUser.getName();
        String email = oauthUser.getAttribute("email");
        String name = oauthUser.getAttribute("name");
        String picture = oauthUser.getAttribute("picture");

        userRepo.findByProviderAndProviderId(provider, providerId)
            .orElseGet(() -> userRepo.save(User.builder()
                .provider(provider).providerId(providerId)
                .email(email).name(name).picture(picture)
                .emailVerified(true).build()));
        return oauthUser;
    }
}
# FastAPI + Authlib
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config

config = Config(".env")
oauth = OAuth(config)

oauth.register(
    name="google",
    client_id=config("GOOGLE_CLIENT_ID"),
    client_secret=config("GOOGLE_CLIENT_SECRET"),
    server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
    client_kwargs={"scope": "openid profile email"},
)

@router.get("/auth/google")
async def google_login(request: Request):
    redirect_uri = request.url_for("google_callback")
    return await oauth.google.authorize_redirect(request, redirect_uri)

@router.get("/auth/google/callback")
async def google_callback(request: Request, db: Session = Depends(get_db)):
    token = await oauth.google.authorize_access_token(request)
    profile = token.get("userinfo")

    user = find_or_create_user(db, provider="google",
        provider_id=profile["sub"], email=profile["email"],
        name=profile.get("name"), picture=profile.get("picture"),
        access_token=token["access_token"])
    
    session_token = create_session(db, user.id)
    response = RedirectResponse("/dashboard")
    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=7 * 24 * 60 * 60)
    return response
// Program.cs — ASP.NET Core
builder.Services.AddAuthentication(options => {
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
})
.AddCookie()
.AddGoogle(options => {
    options.ClientId = builder.Configuration["Google:ClientId"]!;
    options.ClientSecret = builder.Configuration["Google:ClientSecret"]!;
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.SaveTokens = true;
    options.Events.OnCreatingTicket = async ctx => {
        var userService = ctx.HttpContext.RequestServices
            .GetRequiredService<IUserService>();
        await userService.FindOrCreateAsync(new OAuthProfile {
            Provider = "google",
            ProviderId = ctx.Principal!.FindFirstValue(ClaimTypes.NameIdentifier)!,
            Email = ctx.Principal.FindFirstValue(ClaimTypes.Email)!,
            Name = ctx.Principal.FindFirstValue(ClaimTypes.Name),
            Picture = ctx.Principal.FindFirstValue("picture"),
            AccessToken = ctx.AccessToken,
            RefreshToken = ctx.RefreshToken,
        });
    };
});

Routes

// src/routes/auth.ts
import express from 'express';
import passport from 'passport';
import { generateSessionToken } from '@/utils/jwt';

const router = express.Router();

// Initiate Google login
router.get(
  '/google',
  passport.authenticate('google', {
    scope: ['profile', 'email'],
    accessType: 'offline', // Request refresh token
  })
);

// Google callback
router.get(
  '/google/callback',
  passport.authenticate('google', { failureRedirect: '/login?error=true' }),
  (req, res) => {
    // User is authenticated
    const sessionToken = generateSessionToken(req.user);
    
    // Set secure, httpOnly cookie
    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    });

    res.redirect('/dashboard');
  }
);

// Logout
router.post('/logout', (req, res) => {
  req.logout((err) => {
    if (err) return res.status(500).json({ error: 'Logout failed' });
    res.clearCookie('sessionToken');
    res.json({ success: true });
  });
});

export default router;
@RestController
@RequestMapping("/auth")
public class AuthController {

    @GetMapping("/google")
    public void googleLogin(HttpServletResponse response) throws IOException {
        // Spring Security handles redirect automatically via OAuth2LoginConfigurer
        response.sendRedirect("/oauth2/authorization/google");
    }

    // Spring Security handles /login/oauth2/code/google callback automatically.
    // Customize via successHandler in SecurityConfig.

    @PostMapping("/logout")
    public ResponseEntity<Map<String, Object>> logout(
            HttpServletRequest req, HttpServletResponse res,
            Authentication auth) throws Exception {
        new SecurityContextLogoutHandler().logout(req, res, auth);
        // Clear session cookie
        ResponseCookie cookie = ResponseCookie.from("sessionToken", "")
            .httpOnly(true).secure(true).maxAge(0).path("/").build();
        res.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
        return ResponseEntity.ok(Map.of("success", true));
    }
}
from fastapi import APIRouter, Request, Response
from starlette.responses import RedirectResponse, JSONResponse

router = APIRouter()

@router.get("/auth/google")
async def google_login(request: Request):
    redirect_uri = request.url_for("google_callback")
    return await oauth.google.authorize_redirect(request, redirect_uri,
        access_type="offline")

@router.get("/auth/google/callback", name="google_callback")
async def google_callback(request: Request, db: Session = Depends(get_db)):
    token = await oauth.google.authorize_access_token(request)
    profile = token.get("userinfo")
    user = find_or_create_user(db, provider="google",
        provider_id=profile["sub"], email=profile["email"],
        name=profile.get("name"), access_token=token["access_token"])
    session_token = create_session(db, user.id)
    resp = RedirectResponse("/dashboard")
    resp.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=7 * 24 * 60 * 60)
    return resp

@router.post("/auth/logout")
async def logout(response: Response):
    response.delete_cookie("sessionToken")
    return {"success": True}
[ApiController]
[Route("auth")]
public class AuthController : ControllerBase
{
    [HttpGet("google")]
    public IActionResult GoogleLogin() =>
        Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" },
            GoogleDefaults.AuthenticationScheme);

    [HttpGet("google/callback")]
    public async Task<IActionResult> GoogleCallback()
    {
        var result = await HttpContext.AuthenticateAsync(
            CookieAuthenticationDefaults.AuthenticationScheme);
        if (!result.Succeeded)
            return Redirect("/login?error=true");
        return Redirect("/dashboard");
    }

    [HttpPost("logout")]
    public async Task<IActionResult> Logout()
    {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        Response.Cookies.Delete("sessionToken");
        return Ok(new { success = true });
    }
}

User Service

// src/services/auth.ts
import { User } from '@/models/user';
import bcrypt from 'bcrypt';

export async function findOrCreateUser(profile: {
  provider: string;
  providerId: string;
  email: string;
  name: string;
  picture?: string;
  accessToken: string;
  refreshToken?: string;
}) {
  // Look for existing user with this OAuth provider
  let user = await User.findOne({
    'oauth.provider': profile.provider,
    'oauth.providerId': profile.providerId,
  });

  if (user) {
    // Update tokens if user exists
    user.oauth.accessToken = profile.accessToken;
    if (profile.refreshToken) {
      user.oauth.refreshToken = profile.refreshToken;
    }
    await user.save();
    return user;
  }

  // Check if user exists by email
  user = await User.findOne({ email: profile.email });

  if (user) {
    // Link OAuth to existing user
    user.oauth = {
      provider: profile.provider,
      providerId: profile.providerId,
      accessToken: profile.accessToken,
      refreshToken: profile.refreshToken,
    };
    await user.save();
    return user;
  }

  // Create new user
  user = new User({
    email: profile.email,
    name: profile.name,
    picture: profile.picture,
    emailVerified: true, // OAuth providers verify email
    oauth: {
      provider: profile.provider,
      providerId: profile.providerId,
      accessToken: profile.accessToken,
      refreshToken: profile.refreshToken,
    },
  });

  await user.save();
  return user;
}
@Service
@Transactional
public class AuthService {

    @Autowired private UserRepository userRepo;

    public User findOrCreateUser(OAuthProfile profile) {
        // Look for existing user with this OAuth provider
        return userRepo.findByProviderAndProviderId(
                profile.provider(), profile.providerId())
            .map(user -> {
                user.setAccessToken(profile.accessToken());
                if (profile.refreshToken() != null)
                    user.setRefreshToken(profile.refreshToken());
                return userRepo.save(user);
            })
            .orElseGet(() -> userRepo.findByEmail(profile.email())
                .map(user -> {
                    // Link OAuth to existing user
                    user.setProvider(profile.provider());
                    user.setProviderId(profile.providerId());
                    user.setAccessToken(profile.accessToken());
                    user.setRefreshToken(profile.refreshToken());
                    return userRepo.save(user);
                })
                .orElseGet(() -> userRepo.save(User.builder()
                    .email(profile.email())
                    .name(profile.name())
                    .picture(profile.picture())
                    .emailVerified(true)
                    .provider(profile.provider())
                    .providerId(profile.providerId())
                    .accessToken(profile.accessToken())
                    .refreshToken(profile.refreshToken())
                    .build())));
    }
}
from sqlalchemy.orm import Session
from models import User

def find_or_create_user(
    db: Session,
    provider: str,
    provider_id: str,
    email: str,
    name: str,
    picture: str | None = None,
    access_token: str = "",
    refresh_token: str | None = None,
) -> User:
    # Look for existing user with this OAuth provider
    user = db.query(User).filter_by(
        provider=provider, provider_id=provider_id).first()

    if user:
        user.access_token = access_token
        if refresh_token:
            user.refresh_token = refresh_token
        db.commit()
        return user

    # Check if user exists by email
    user = db.query(User).filter_by(email=email).first()
    if user:
        user.provider = provider
        user.provider_id = provider_id
        user.access_token = access_token
        user.refresh_token = refresh_token
        db.commit()
        return user

    # Create new user
    user = User(
        email=email, name=name, picture=picture,
        email_verified=True, provider=provider,
        provider_id=provider_id, access_token=access_token,
        refresh_token=refresh_token,
    )
    db.add(user)
    db.commit()
    return user
public class AuthService : IAuthService
{
    private readonly AppDbContext _db;

    public AuthService(AppDbContext db) => _db = db;

    public async Task<User> FindOrCreateUserAsync(OAuthProfile profile)
    {
        // Look for existing user with this OAuth provider
        var user = await _db.Users.FirstOrDefaultAsync(u =>
            u.Provider == profile.Provider &&
            u.ProviderId == profile.ProviderId);

        if (user != null)
        {
            user.AccessToken = profile.AccessToken;
            if (profile.RefreshToken != null)
                user.RefreshToken = profile.RefreshToken;
            await _db.SaveChangesAsync();
            return user;
        }

        // Check if user exists by email
        user = await _db.Users.FirstOrDefaultAsync(u => u.Email == profile.Email);
        if (user != null)
        {
            user.Provider = profile.Provider;
            user.ProviderId = profile.ProviderId;
            user.AccessToken = profile.AccessToken;
            user.RefreshToken = profile.RefreshToken;
            await _db.SaveChangesAsync();
            return user;
        }

        // Create new user
        user = new User {
            Email = profile.Email, Name = profile.Name,
            Picture = profile.Picture, EmailVerified = true,
            Provider = profile.Provider, ProviderId = profile.ProviderId,
            AccessToken = profile.AccessToken, RefreshToken = profile.RefreshToken,
        };
        _db.Users.Add(user);
        await _db.SaveChangesAsync();
        return user;
    }
}

Implementing GitHub OAuth

GitHub OAuth follows the same pattern but with provider-specific configuration:

// src/config/passport-github.ts
import passport from 'passport';
import { Strategy as GitHubStrategy } from 'passport-github2';
import { findOrCreateUser } from '@/services/auth';

passport.use(
  'github',
  new GitHubStrategy(
    {
      clientID: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
      callbackURL: process.env.GITHUB_CALLBACK_URL || 'http://localhost:3000/auth/github/callback',
      scope: ['user:email', 'repo'], // Request specific scopes
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        const primaryEmail = profile.emails?.find(e => e.primary)?.value || 
                           profile.emails?.[0]?.value;

        const user = await findOrCreateUser({
          provider: 'github',
          providerId: String(profile.id),
          email: primaryEmail!,
          name: profile.displayName || profile.username || '',
          picture: profile.photos?.[0]?.value,
          accessToken,
        });

        done(null, user);
      } catch (error) {
        done(error);
      }
    }
  )
);
// Spring Boot — application.properties
// spring.security.oauth2.client.registration.github.client-id=${GITHUB_CLIENT_ID}
// spring.security.oauth2.client.registration.github.client-secret=${GITHUB_CLIENT_SECRET}
// spring.security.oauth2.client.registration.github.scope=user:email,repo

@Service
public class GitHubOAuth2UserService extends DefaultOAuth2UserService {
    @Autowired private UserRepository userRepo;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest req) {
        OAuth2User oauthUser = super.loadUser(req);
        String providerId = String.valueOf(oauthUser.getAttribute("id"));
        String name = oauthUser.getAttribute("name");
        if (name == null) name = oauthUser.getAttribute("login");
        String picture = oauthUser.getAttribute("avatar_url");
        // Fetch primary email via GitHub API if needed
        String email = oauthUser.getAttribute("email");
        String accessToken = req.getAccessToken().getTokenValue();

        userRepo.findByProviderAndProviderId("github", providerId)
            .orElseGet(() -> userRepo.save(User.builder()
                .provider("github").providerId(providerId)
                .email(email).name(name).picture(picture)
                .accessToken(accessToken)
                .emailVerified(true).build()));
        return oauthUser;
    }
}
oauth.register(
    name="github",
    client_id=config("GITHUB_CLIENT_ID"),
    client_secret=config("GITHUB_CLIENT_SECRET"),
    access_token_url="https://github.com/login/oauth/access_token",
    authorize_url="https://github.com/login/oauth/authorize",
    api_base_url="https://api.github.com/",
    client_kwargs={"scope": "user:email repo"},
)

@router.get("/auth/github/callback")
async def github_callback(request: Request, db: Session = Depends(get_db)):
    token = await oauth.github.authorize_access_token(request)
    profile_resp = await oauth.github.get("user", token=token)
    profile = profile_resp.json()

    # Get primary email if not public
    emails_resp = await oauth.github.get("user/emails", token=token)
    emails = emails_resp.json()
    primary_email = next(
        (e["email"] for e in emails if e.get("primary")),
        profile.get("email"))

    user = find_or_create_user(db, provider="github",
        provider_id=str(profile["id"]),
        email=primary_email,
        name=profile.get("name") or profile.get("login", ""),
        picture=profile.get("avatar_url"),
        access_token=token["access_token"])
    
    session_token = create_session(db, user.id)
    resp = RedirectResponse("/dashboard")
    resp.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=7 * 24 * 60 * 60)
    return resp
// Program.cs
builder.Services.AddAuthentication()
    .AddGitHub(options => {
        options.ClientId = builder.Configuration["GitHub:ClientId"]!;
        options.ClientSecret = builder.Configuration["GitHub:ClientSecret"]!;
        options.Scope.Add("user:email");
        options.Scope.Add("repo");
        options.SaveTokens = true;
        options.Events.OnCreatingTicket = async ctx => {
            var userService = ctx.HttpContext.RequestServices
                .GetRequiredService<IUserService>();
            var primaryEmail = ctx.Principal!
                .FindFirstValue(ClaimTypes.Email) ?? "";
            await userService.FindOrCreateAsync(new OAuthProfile {
                Provider = "github",
                ProviderId = ctx.Principal.FindFirstValue(ClaimTypes.NameIdentifier)!,
                Email = primaryEmail,
                Name = ctx.Principal.FindFirstValue(ClaimTypes.Name),
                Picture = ctx.Principal.FindFirstValue("urn:github:avatar"),
                AccessToken = ctx.AccessToken,
            });
        };
    });

Routes are identical to Google, just swap the provider name:

// GitHub routes
router.get('/github', passport.authenticate('github', { scope: ['user:email'] }));

router.get(
  '/github/callback',
  passport.authenticate('github', { failureRedirect: '/login' }),
  (req, res) => {
    const sessionToken = generateSessionToken(req.user);
    res.cookie('sessionToken', sessionToken, { /* ... */ });
    res.redirect('/dashboard');
  }
);
@GetMapping("/github")
public void githubLogin(HttpServletResponse response) throws IOException {
    response.sendRedirect("/oauth2/authorization/github");
}

// Spring Security handles /login/oauth2/code/github automatically.
// Success redirects to /dashboard via successHandler in SecurityConfig.
@router.get("/auth/github")
async def github_login(request: Request):
    redirect_uri = request.url_for("github_callback")
    return await oauth.github.authorize_redirect(request, redirect_uri)
[HttpGet("github")]
public IActionResult GitHubLogin() =>
    Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" },
        "GitHub");

Accessing User’s GitHub Repositories

Once authenticated, use the stored access token to call GitHub’s API:

// src/services/github.ts
export async function getUserRepositories(accessToken: string) {
  const response = await fetch('https://api.github.com/user/repos', {
    headers: {
      Authorization: `token ${accessToken}`,
      Accept: 'application/vnd.github.v3+json',
    },
  });

  if (!response.ok) {
    throw new Error(`GitHub API error: ${response.statusText}`);
  }

  return response.json();
}
@Service
public class GitHubService {
    private final RestTemplate restTemplate = new RestTemplate();

    public List<Map<String, Object>> getUserRepositories(String accessToken) {
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "token " + accessToken);
        headers.set("Accept", "application/vnd.github.v3+json");

        ResponseEntity<List> response = restTemplate.exchange(
            "https://api.github.com/user/repos",
            HttpMethod.GET,
            new HttpEntity<>(headers),
            List.class);

        if (!response.getStatusCode().is2xxSuccessful()) {
            throw new RuntimeException("GitHub API error: " + response.getStatusCode());
        }
        return response.getBody();
    }
}
import httpx
from typing import Any

async def get_user_repositories(access_token: str) -> list[dict[str, Any]]:
    async with httpx.AsyncClient() as client:
        response = await client.get(
            "https://api.github.com/user/repos",
            headers={
                "Authorization": f"token {access_token}",
                "Accept": "application/vnd.github.v3+json",
            },
        )
        if not response.is_success:
            raise RuntimeError(f"GitHub API error: {response.status_code}")
        return response.json()
public class GitHubService
{
    private readonly HttpClient _http;

    public GitHubService(HttpClient http) => _http = http;

    public async Task<List<JsonElement>> GetUserRepositoriesAsync(string accessToken)
    {
        using var request = new HttpRequestMessage(
            HttpMethod.Get, "https://api.github.com/user/repos");
        request.Headers.Add("Authorization", $"token {accessToken}");
        request.Headers.Add("Accept", "application/vnd.github.v3+json");
        request.Headers.Add("User-Agent", "MyApp/1.0");

        var response = await _http.SendAsync(request);
        response.EnsureSuccessStatusCode();
        var json = await response.Content.ReadAsStringAsync();
        return JsonSerializer.Deserialize<List<JsonElement>>(json)!;
    }
}

Managing OAuth Tokens

OAuth introduces three types of tokens:

Access Token

  • Short-lived (typically 1 hour)
  • Used to authenticate API requests
  • Should be stored securely
  • Never exposed to the browser in localStorage
// Use access token for API requests
const headers = {
  Authorization: `Bearer ${accessToken}`,
};
// Use access token for API requests
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
# Use access token for API requests
headers = {"Authorization": f"Bearer {access_token}"}
// Use access token for API requests
httpClient.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

Refresh Token

  • Long-lived (days to months)
  • Exchanged for a new access token when it expires
  • Should be stored securely (httpOnly cookie or secure storage)
  • Never sent to the browser

ID Token

  • OpenID Connect only
  • Contains user identity claims (name, email, picture, etc.)
  • JWT format, can be decoded client-side (but always verify signature server-side)
// Decode ID token (client-side only for UI)
const decoded = jwtDecode(idToken);
console.log(decoded.email, decoded.name);

Token Refresh Implementation

// src/services/token.ts
export async function refreshAccessToken(refreshToken: string) {
  const response = await fetch('https://provider.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: process.env.OAUTH_CLIENT_ID!,
      client_secret: process.env.OAUTH_CLIENT_SECRET!,
    }),
  });

  if (!response.ok) {
    throw new Error('Token refresh failed');
  }

  const data = await response.json();
  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token || refreshToken,
    expiresIn: data.expires_in,
  };
}

// Middleware to refresh token if needed
export async function ensureValidToken(user: any) {
  if (!user.oauth?.accessToken) {
    throw new Error('No access token');
  }

  // Check if token expires in less than 5 minutes
  const expiresAt = user.oauth.accessTokenExpiresAt;
  if (expiresAt && new Date() > new Date(expiresAt.getTime() - 5 * 60 * 1000)) {
    const refreshed = await refreshAccessToken(user.oauth.refreshToken);
    user.oauth.accessToken = refreshed.accessToken;
    user.oauth.accessTokenExpiresAt = new Date(Date.now() + refreshed.expiresIn * 1000);
    await user.save();
  }

  return user.oauth.accessToken;
}
@Service
public class TokenService {
    @Autowired private UserRepository userRepo;
    private final RestTemplate restTemplate = new RestTemplate();

    @Value("${oauth.client-id}") private String clientId;
    @Value("${oauth.client-secret}") private String clientSecret;

    public TokenResponse refreshAccessToken(String refreshToken) {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "refresh_token");
        body.add("refresh_token", refreshToken);
        body.add("client_id", clientId);
        body.add("client_secret", clientSecret);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        ResponseEntity<Map> resp = restTemplate.postForEntity(
            "https://provider.com/token",
            new HttpEntity<>(body, headers), Map.class);

        if (!resp.getStatusCode().is2xxSuccessful())
            throw new RuntimeException("Token refresh failed");

        Map<?, ?> data = resp.getBody();
        String newRefresh = data.containsKey("refresh_token")
            ? (String) data.get("refresh_token") : refreshToken;
        return new TokenResponse(
            (String) data.get("access_token"),
            newRefresh,
            (Integer) data.get("expires_in"));
    }

    public String ensureValidToken(User user) {
        if (user.getAccessToken() == null)
            throw new IllegalStateException("No access token");

        Instant expiresAt = user.getAccessTokenExpiresAt();
        if (expiresAt != null &&
                Instant.now().isAfter(expiresAt.minusSeconds(300))) {
            TokenResponse refreshed = refreshAccessToken(user.getRefreshToken());
            user.setAccessToken(refreshed.accessToken());
            user.setAccessTokenExpiresAt(
                Instant.now().plusSeconds(refreshed.expiresIn()));
            userRepo.save(user);
        }
        return user.getAccessToken();
    }
}
import httpx
from datetime import datetime, timedelta, timezone

async def refresh_access_token(refresh_token: str) -> dict:
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://provider.com/token",
            data={
                "grant_type": "refresh_token",
                "refresh_token": refresh_token,
                "client_id": settings.OAUTH_CLIENT_ID,
                "client_secret": settings.OAUTH_CLIENT_SECRET,
            },
        )
        if not response.is_success:
            raise RuntimeError("Token refresh failed")
        data = response.json()
        return {
            "access_token": data["access_token"],
            "refresh_token": data.get("refresh_token", refresh_token),
            "expires_in": data["expires_in"],
        }

async def ensure_valid_token(user: User, db: Session) -> str:
    if not user.access_token:
        raise ValueError("No access token")

    expires_at = user.access_token_expires_at
    if expires_at and datetime.now(timezone.utc) > expires_at - timedelta(minutes=5):
        refreshed = await refresh_access_token(user.refresh_token)
        user.access_token = refreshed["access_token"]
        user.access_token_expires_at = (
            datetime.now(timezone.utc) + timedelta(seconds=refreshed["expires_in"]))
        db.commit()

    return user.access_token
public class TokenService
{
    private readonly HttpClient _http;
    private readonly AppDbContext _db;
    private readonly IConfiguration _config;

    public TokenService(HttpClient http, AppDbContext db, IConfiguration config)
    { _http = http; _db = db; _config = config; }

    public async Task<TokenResponse> RefreshAccessTokenAsync(string refreshToken)
    {
        var content = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["grant_type"] = "refresh_token",
            ["refresh_token"] = refreshToken,
            ["client_id"] = _config["OAuth:ClientId"]!,
            ["client_secret"] = _config["OAuth:ClientSecret"]!,
        });

        var response = await _http.PostAsync("https://provider.com/token", content);
        response.EnsureSuccessStatusCode();
        var data = await response.Content.ReadFromJsonAsync<JsonDocument>();
        var root = data!.RootElement;

        return new TokenResponse(
            AccessToken: root.GetProperty("access_token").GetString()!,
            RefreshToken: root.TryGetProperty("refresh_token", out var rt)
                ? rt.GetString()! : refreshToken,
            ExpiresIn: root.GetProperty("expires_in").GetInt32());
    }

    public async Task<string> EnsureValidTokenAsync(User user)
    {
        if (user.AccessToken == null)
            throw new InvalidOperationException("No access token");

        if (user.AccessTokenExpiresAt.HasValue &&
            DateTimeOffset.UtcNow > user.AccessTokenExpiresAt.Value.AddMinutes(-5))
        {
            var refreshed = await RefreshAccessTokenAsync(user.RefreshToken!);
            user.AccessToken = refreshed.AccessToken;
            user.AccessTokenExpiresAt = DateTimeOffset.UtcNow
                .AddSeconds(refreshed.ExpiresIn);
            await _db.SaveChangesAsync();
        }
        return user.AccessToken;
    }
}

Handling User Creation and Linking

When users first authenticate with OAuth, you need to decide:

  1. Create a new account immediately
  2. Match to existing email
  3. Require account linking
export async function findOrCreateUser(profile: OAuthProfile) {
  // Strategy: Link by OAuth provider first, then by email
  
  let user = await User.findOne({
    $or: [
      { 'oauth.provider': profile.provider, 'oauth.providerId': profile.providerId },
      { email: profile.email }, // Link by email if not yet linked
    ],
  });

  if (!user) {
    // Create new user
    user = new User({
      email: profile.email,
      name: profile.name,
      emailVerified: true, // Assume OAuth provider verified email
    });
  } else if (!user.oauth) {
    // User exists by email, link the OAuth account
    user.oauth = {
      provider: profile.provider,
      providerId: profile.providerId,
    };
  }

  // Always update tokens
  user.oauth.accessToken = profile.accessToken;
  user.oauth.refreshToken = profile.refreshToken;
  await user.save();

  return user;
}
@Transactional
public User findOrCreateUser(OAuthProfile profile) {
    // Link by OAuth provider first, then by email
    User user = userRepo
        .findByProviderAndProviderId(profile.provider(), profile.providerId())
        .or(() -> userRepo.findByEmail(profile.email()))
        .orElse(null);

    if (user == null) {
        user = User.builder()
            .email(profile.email())
            .name(profile.name())
            .emailVerified(true)
            .build();
    } else if (user.getProvider() == null) {
        // User exists by email, link the OAuth account
        user.setProvider(profile.provider());
        user.setProviderId(profile.providerId());
    }

    // Always update tokens
    user.setAccessToken(profile.accessToken());
    user.setRefreshToken(profile.refreshToken());
    return userRepo.save(user);
}
def find_or_create_user(db: Session, profile: OAuthProfile) -> User:
    # Link by OAuth provider first, then by email
    user = (db.query(User)
        .filter_by(provider=profile.provider, provider_id=profile.provider_id)
        .first()
        or db.query(User).filter_by(email=profile.email).first())

    if user is None:
        user = User(
            email=profile.email,
            name=profile.name,
            email_verified=True,
        )
        db.add(user)
    elif user.provider is None:
        user.provider = profile.provider
        user.provider_id = profile.provider_id

    user.access_token = profile.access_token
    user.refresh_token = profile.refresh_token
    db.commit()
    return user
public async Task<User> FindOrCreateUserAsync(OAuthProfile profile)
{
    var user = await _db.Users.FirstOrDefaultAsync(u =>
        (u.Provider == profile.Provider && u.ProviderId == profile.ProviderId)
        || u.Email == profile.Email);

    if (user == null)
    {
        user = new User {
            Email = profile.Email,
            Name = profile.Name,
            EmailVerified = true,
        };
        _db.Users.Add(user);
    }
    else if (user.Provider == null)
    {
        user.Provider = profile.Provider;
        user.ProviderId = profile.ProviderId;
    }

    user.AccessToken = profile.AccessToken;
    user.RefreshToken = profile.RefreshToken;
    await _db.SaveChangesAsync();
    return user;
}

Strategy: Require Explicit Linking

For more control, show a linking page:

// Route: Display linking page
router.get('/auth/google/callback', (req, res) => {
  const profile = req.user; // From Passport verify callback
  
  // Check if user exists
  const existingUser = await User.findOne({ email: profile.email });
  
  if (existingUser && !existingUser.oauth) {
    // Show linking page
    const token = generateLinkingToken(profile);
    return res.redirect(`/auth/link?token=${token}`);
  }
  
  // Otherwise, normal flow
});

// Linking endpoint
router.post('/auth/link', authenticateUser, async (req, res) => {
  const linkingToken = req.body.token;
  const profile = decodeLinkingToken(linkingToken);
  
  req.user.oauth = {
    provider: profile.provider,
    providerId: profile.providerId,
    accessToken: profile.accessToken,
  };
  
  await req.user.save();
  res.json({ success: true });
});
@GetMapping("/auth/google/callback")
public RedirectView googleCallback(Authentication auth,
        HttpServletRequest req) {
    OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) auth;
    String email = token.getPrincipal().getAttribute("email");

    Optional<User> existingUser = userRepo.findByEmail(email);
    if (existingUser.isPresent() && existingUser.get().getProvider() == null) {
        String linkingToken = generateLinkingToken(token.getPrincipal());
        return new RedirectView("/auth/link?token=" + linkingToken);
    }
    return new RedirectView("/dashboard");
}

@PostMapping("/auth/link")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, Object>> linkAccount(
        @RequestBody LinkRequest body,
        @AuthenticationPrincipal UserDetails currentUser) {
    OAuthProfile profile = decodeLinkingToken(body.token());
    User user = userRepo.findByEmail(currentUser.getUsername()).orElseThrow();
    user.setProvider(profile.provider());
    user.setProviderId(profile.providerId());
    user.setAccessToken(profile.accessToken());
    userRepo.save(user);
    return ResponseEntity.ok(Map.of("success", true));
}
@router.get("/auth/google/callback")
async def google_callback(request: Request, db: Session = Depends(get_db)):
    token = await oauth.google.authorize_access_token(request)
    profile = token.get("userinfo")

    existing_user = db.query(User).filter_by(email=profile["email"]).first()
    if existing_user and existing_user.provider is None:
        linking_token = generate_linking_token(profile)
        return RedirectResponse(f"/auth/link?token={linking_token}")

    # Normal flow
    user = find_or_create_user(db, provider="google", ...)
    return create_session_response(user)

@router.post("/auth/link")
async def link_account(
    body: LinkRequest,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    profile = decode_linking_token(body.token)
    current_user.provider = profile["provider"]
    current_user.provider_id = profile["provider_id"]
    current_user.access_token = profile["access_token"]
    db.commit()
    return {"success": True}
[HttpGet("google/callback")]
public async Task<IActionResult> GoogleCallback()
{
    var result = await HttpContext.AuthenticateAsync(
        CookieAuthenticationDefaults.AuthenticationScheme);
    var email = result.Principal?.FindFirstValue(ClaimTypes.Email);

    var existingUser = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
    if (existingUser != null && existingUser.Provider == null)
    {
        var linkingToken = GenerateLinkingToken(result.Principal!);
        return Redirect($"/auth/link?token={linkingToken}");
    }
    return Redirect("/dashboard");
}

[HttpPost("link")]
[Authorize]
public async Task<IActionResult> LinkAccount([FromBody] LinkRequest body)
{
    var profile = DecodeLinkingToken(body.Token);
    var user = await _db.Users.FirstAsync(u =>
        u.Email == User.FindFirstValue(ClaimTypes.Email));
    user.Provider = profile.Provider;
    user.ProviderId = profile.ProviderId;
    user.AccessToken = profile.AccessToken;
    await _db.SaveChangesAsync();
    return Ok(new { success = true });
}

Enterprise SSO: SAML and OIDC

Enterprise customers often require SAML 2.0 or OpenID Connect for single sign-on (SSO).

Azure AD / Entra ID Setup

// src/config/passport-azure-ad.ts
import passport from 'passport';
import { BearerStrategy } from 'passport-azure-ad';
import jwt from 'jsonwebtoken';

passport.use(
  'azure-ad',
  new BearerStrategy(
    {
      clientID: process.env.AZURE_CLIENT_ID!,
      audience: process.env.AZURE_CLIENT_ID!,
    },
    async (token: any, done) => {
      try {
        // Token is already validated by BearerStrategy
        const user = await User.findOne({
          'oauth.provider': 'azure-ad',
          'oauth.providerId': token.oid,
        });

        if (!user) {
          return done(null, false, { message: 'User not found' });
        }

        done(null, user, token);
      } catch (error) {
        done(error);
      }
    }
  )
);
// Spring Boot with Azure AD starter
// application.properties:
// spring.cloud.azure.active-directory.enabled=true
// spring.cloud.azure.active-directory.credential.client-id=${AZURE_CLIENT_ID}
// spring.cloud.azure.active-directory.credential.client-secret=${AZURE_CLIENT_SECRET}
// spring.cloud.azure.active-directory.profile.tenant-id=${AZURE_TENANT_ID}

@Configuration
@EnableWebSecurity
public class AzureSecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.apply(AadWebApplicationHttpSecurityConfigurer.aadWebApplication())
            .and()
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated());
        return http.build();
    }
}

@Service
public class AzureUserService {
    @Autowired private UserRepository userRepo;

    public User loadFromToken(OidcUser oidcUser) {
        String oid = oidcUser.getClaim("oid");
        return userRepo.findByProviderAndProviderId("azure-ad", oid)
            .orElseThrow(() -> new UsernameNotFoundException("User not found"));
    }
}
from fastapi_azure_auth import SingleTenantAzureAuthorizationCodeBearer

azure_scheme = SingleTenantAzureAuthorizationCodeBearer(
    app_client_id=settings.AZURE_CLIENT_ID,
    tenant_id=settings.AZURE_TENANT_ID,
    scopes={f"api://{settings.AZURE_CLIENT_ID}/user_impersonation": "user_impersonation"},
)

@router.get("/protected")
async def protected_route(
    token: dict = Security(azure_scheme),
    db: Session = Depends(get_db),
):
    oid = token.get("oid")
    user = db.query(User).filter_by(provider="azure-ad", provider_id=oid).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return {"user": user}
// Program.cs — Azure AD OIDC
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));

// appsettings.json:
// "AzureAd": {
//   "Instance": "https://login.microsoftonline.com/",
//   "TenantId": "YOUR_TENANT_ID",
//   "ClientId": "YOUR_CLIENT_ID",
//   "Audience": "YOUR_CLIENT_ID"
// }

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ProtectedController : ControllerBase
{
    private readonly AppDbContext _db;
    public ProtectedController(AppDbContext db) => _db = db;

    [HttpGet]
    public async Task<IActionResult> Get()
    {
        var oid = User.FindFirstValue("oid");
        var user = await _db.Users.FirstOrDefaultAsync(u =>
            u.Provider == "azure-ad" && u.ProviderId == oid);
        if (user == null)
            return NotFound(new { message = "User not found" });
        return Ok(new { user });
    }
}

SAML 2.0 with Passport (Okta, Azure AD, others)

import { Strategy as SamlStrategy } from 'passport-saml';

passport.use(
  'saml',
  new SamlStrategy(
    {
      path: '/auth/saml/callback',
      entryPoint: process.env.SAML_ENTRY_POINT!,
      issuer: 'your-app-identifier',
      cert: process.env.SAML_CERT!,
      identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
    },
    async (profile: any, done) => {
      try {
        let user = await User.findOne({
          email: profile.email || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'],
        });

        if (!user) {
          user = new User({
            email: profile.email,
            name: profile.displayName,
            emailVerified: true,
            oauth: {
              provider: 'saml',
              providerId: profile.nameID,
            },
          });
          await user.save();
        }

        done(null, user);
      } catch (error) {
        done(error);
      }
    }
  )
);

// Routes
app.get('/auth/saml', passport.authenticate('saml', { failureRedirect: '/login' }));

app.post(
  '/auth/saml/callback',
  passport.authenticate('saml', { failureRedirect: '/login' }),
  (req, res) => {
    const sessionToken = generateSessionToken(req.user);
    res.cookie('sessionToken', sessionToken, { /* ... */ });
    res.redirect('/dashboard');
  }
);

// SAML metadata endpoint (for IdP configuration)
app.get('/auth/saml/metadata', (req, res) => {
  res.type('application/xml');
  res.send(new SamlStrategy(/* ... */).generateServiceProviderMetadata());
});
// Spring Security SAML 2.0
// pom.xml: spring-security-saml2-service-provider

@Configuration
@EnableWebSecurity
public class SamlSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .saml2Login(saml2 -> saml2
                .relyingPartyRegistrationRepository(relyingPartyRepo())
                .successHandler((req, res, auth) -> res.sendRedirect("/dashboard")))
            .saml2Logout(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public RelyingPartyRegistrationRepository relyingPartyRepo() {
        RelyingPartyRegistration registration = RelyingPartyRegistration
            .withRegistrationId("saml")
            .entityId("your-app-identifier")
            .assertionConsumerServiceLocation("/auth/saml/callback")
            .assertingPartyDetails(party -> party
                .entityId(System.getenv("SAML_IDP_ENTITY_ID"))
                .singleSignOnServiceLocation(System.getenv("SAML_ENTRY_POINT"))
                .verificationX509Credentials(c -> c.add(
                    Saml2X509Credential.verification(loadCert()))))
            .build();
        return new InMemoryRelyingPartyRegistrationRepository(registration);
    }
}

// SAML metadata endpoint — Spring generates automatically at:
// GET /saml2/service-provider-metadata/{registrationId}
# Flask-SAML2 or python3-saml
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from fastapi import Request

def init_saml_auth(req: dict) -> OneLogin_Saml2_Auth:
    return OneLogin_Saml2_Auth(req, custom_base_path="saml/")

@router.get("/auth/saml")
async def saml_login(request: Request):
    saml_req = await prepare_fastapi_request(request)
    auth = init_saml_auth(saml_req)
    return RedirectResponse(auth.login())

@router.post("/auth/saml/callback")
async def saml_callback(request: Request, db: Session = Depends(get_db)):
    saml_req = await prepare_fastapi_request(request)
    auth = init_saml_auth(saml_req)
    auth.process_response()
    if not auth.is_authenticated():
        raise HTTPException(status_code=401, detail="SAML authentication failed")

    attrs = auth.get_attributes()
    email = attrs.get("email", [None])[0] or auth.get_nameid()
    name = attrs.get("displayName", [None])[0]

    user = db.query(User).filter_by(email=email).first()
    if not user:
        user = User(email=email, name=name, email_verified=True,
            provider="saml", provider_id=auth.get_nameid())
        db.add(user)
        db.commit()

    return create_session_response(user)

@router.get("/auth/saml/metadata")
async def saml_metadata(request: Request):
    saml_req = await prepare_fastapi_request(request)
    auth = init_saml_auth(saml_req)
    metadata = auth.get_settings().get_sp_metadata()
    return Response(content=metadata, media_type="application/xml")
// Program.cs — SAML 2.0 with Sustainsys.Saml2
builder.Services.AddAuthentication()
    .AddSaml2(options => {
        options.SPOptions.EntityId = new EntityId("your-app-identifier");
        options.IdentityProviders.Add(new IdentityProvider(
            new EntityId(Environment.GetEnvironmentVariable("SAML_IDP_ENTITY_ID")),
            options.SPOptions)
        {
            MetadataLocation = Environment.GetEnvironmentVariable("SAML_IDP_METADATA_URL"),
            LoadMetadata = true,
        });
    });

[ApiController]
[Route("auth/saml")]
public class SamlController : ControllerBase
{
    [HttpGet("")]
    public IActionResult SamlLogin() =>
        Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" },
            Saml2Defaults.Scheme);

    // Callback is handled automatically by Sustainsys.Saml2 middleware
    // at POST /Saml2/Acs

    [HttpGet("metadata")]
    public IActionResult Metadata()
    {
        // Sustainsys exposes metadata at GET /Saml2
        return Redirect("/Saml2");
    }
}

Security: Critical Implementation Details

1. State Parameter

Always use the state parameter to prevent CSRF attacks:

import crypto from 'crypto';

// Generate state before redirect
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;

// Include in authorization URL
const authUrl = `https://provider.com/authorize?client_id=...&state=${state}`;

// Verify on callback
if (req.query.state !== req.session.oauthState) {
  return res.status(400).json({ error: 'State mismatch' });
}
import java.security.SecureRandom;

// Generate state before redirect
SecureRandom sr = new SecureRandom();
byte[] stateBytes = new byte[32];
sr.nextBytes(stateBytes);
String state = HexFormat.of().formatHex(stateBytes);
session.setAttribute("oauthState", state);

// Include in authorization URL
String authUrl = "https://provider.com/authorize?client_id=...&state=" + state;

// Verify on callback
String returnedState = request.getParameter("state");
String savedState = (String) session.getAttribute("oauthState");
if (!MessageDigest.isEqual(returnedState.getBytes(), savedState.getBytes())) {
    return ResponseEntity.status(400).body(Map.of("error", "State mismatch"));
}
import os

# Generate state before redirect
state = os.urandom(32).hex()
request.session["oauth_state"] = state

# Include in authorization URL
auth_url = f"https://provider.com/authorize?client_id=...&state={state}"

# Verify on callback
returned_state = request.query_params.get("state")
saved_state = request.session.get("oauth_state")
if not secrets.compare_digest(returned_state or "", saved_state or ""):
    raise HTTPException(status_code=400, detail="State mismatch")
using System.Security.Cryptography;

// Generate state before redirect
var stateBytes = RandomNumberGenerator.GetBytes(32);
var state = Convert.ToHexString(stateBytes).ToLower();
HttpContext.Session.SetString("OAuthState", state);

// Include in authorization URL
var authUrl = $"https://provider.com/authorize?client_id=...&state={state}";

// Verify on callback
var returnedState = Request.Query["state"].ToString();
var savedState = HttpContext.Session.GetString("OAuthState") ?? "";
if (!CryptographicOperations.FixedTimeEquals(
        System.Text.Encoding.UTF8.GetBytes(returnedState),
        System.Text.Encoding.UTF8.GetBytes(savedState)))
{
    return BadRequest(new { error = "State mismatch" });
}

2. PKCE for Mobile and SPAs

PKCE is mandatory for:

  • Mobile apps
  • Single Page Applications (SPAs)
  • Native desktop apps
// Client-side: Generate and store code verifier
const codeVerifier = base64url(crypto.randomBytes(32));
sessionStorage.setItem('pkce_verifier', codeVerifier);

// Server-side: Verify code challenge
function verifyCodeChallenge(codeVerifier: string, codeChallenge: string): boolean {
  const hash = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
  return hash === codeChallenge;
}
// Client-side (Android/desktop): Generate and store code verifier
SecureRandom sr = new SecureRandom();
byte[] bytes = new byte[32];
sr.nextBytes(bytes);
String codeVerifier = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
// Store in secure prefs / session

// Server-side: Verify code challenge
public boolean verifyCodeChallenge(String codeVerifier, String codeChallenge) throws Exception {
    MessageDigest digest = MessageDigest.getInstance("SHA-256");
    byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
    String computed = Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
    return MessageDigest.isEqual(computed.getBytes(), codeChallenge.getBytes());
}
import secrets, hashlib, base64

# Client-side: Generate and store code verifier
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()
# Store in session

# Server-side: Verify code challenge
def verify_code_challenge(code_verifier: str, code_challenge: str) -> bool:
    digest = hashlib.sha256(code_verifier.encode()).digest()
    computed = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
    return secrets.compare_digest(computed, code_challenge)
// Client-side: Generate and store code verifier
var bytes = RandomNumberGenerator.GetBytes(32);
var codeVerifier = Base64UrlEncode(bytes);
// Store in session / secure storage

// Server-side: Verify code challenge
public static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge)
{
    var hash = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
    var computed = Base64UrlEncode(hash);
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(computed),
        Encoding.UTF8.GetBytes(codeChallenge));
}

3. Redirect URI Validation

Always validate redirect URIs:

const ALLOWED_REDIRECTS = [
  'https://yourapp.com/auth/callback',
  'https://app.yourapp.com/auth/callback',
];

function validateRedirectUri(redirectUri: string): boolean {
  return ALLOWED_REDIRECTS.includes(redirectUri);
}
private static final Set<String> ALLOWED_REDIRECTS = Set.of(
    "https://yourapp.com/auth/callback",
    "https://app.yourapp.com/auth/callback"
);

public boolean validateRedirectUri(String redirectUri) {
    return ALLOWED_REDIRECTS.contains(redirectUri);
}
ALLOWED_REDIRECTS = {
    "https://yourapp.com/auth/callback",
    "https://app.yourapp.com/auth/callback",
}

def validate_redirect_uri(redirect_uri: str) -> bool:
    return redirect_uri in ALLOWED_REDIRECTS
private static readonly HashSet<string> AllowedRedirects = new()
{
    "https://yourapp.com/auth/callback",
    "https://app.yourapp.com/auth/callback",
};

public static bool ValidateRedirectUri(string redirectUri) =>
    AllowedRedirects.Contains(redirectUri);

Never use regex or wildcards. Be explicit.

4. Token Storage

DO:

  • Store access tokens in memory (lost on page refresh, but secure)
  • Store refresh tokens in httpOnly, secure cookies
  • Store user data in Vuex/Redux, not localStorage

DON’T:

  • Store access tokens in localStorage (vulnerable to XSS)
  • Store tokens in cookies without httpOnly flag
  • Log or transmit tokens in URLs or referer headers
// Secure token storage
export class AuthService {
  private accessToken: string | null = null;

  setAccessToken(token: string) {
    this.accessToken = token;
  }

  getAccessToken(): string | null {
    return this.accessToken;
  }

  // Refresh token in httpOnly cookie (handled by browser automatically)
}
@Service
@RequestScope
public class AuthService {
    // Store access token in request-scoped bean (memory only, never persisted to client)
    private String accessToken;

    public void setAccessToken(String token) {
        this.accessToken = token;
    }

    public String getAccessToken() {
        return accessToken;
    }

    // Refresh token is managed via HttpOnly cookie by the servlet container
}
# Store access token in server-side session (Redis/DB backed), not client storage
class AuthService:
    def __init__(self):
        self._access_token: str | None = None  # In-memory only

    def set_access_token(self, token: str) -> None:
        self._access_token = token

    def get_access_token(self) -> str | None:
        return self._access_token

    # Refresh token is stored in HttpOnly cookie set by the server
// Scoped service — token lives only for the duration of the HTTP request
public class AuthService : IAuthService
{
    private string? _accessToken;

    public void SetAccessToken(string token) => _accessToken = token;

    public string? GetAccessToken() => _accessToken;

    // Refresh token lives in an HttpOnly cookie — never exposed to JavaScript
}

// Program.cs
builder.Services.AddScoped<IAuthService, AuthService>();

5. Token Validation

Always validate tokens server-side:

import jwt from 'jsonwebtoken';

export function validateToken(token: string): any {
  try {
    // Verify signature and expiration
    const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ['HS256'],
      issuer: process.env.TOKEN_ISSUER,
      audience: process.env.TOKEN_AUDIENCE,
    });

    return decoded;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new Error('Token expired');
    }
    throw new Error('Invalid token');
  }
}
import io.jsonwebtoken.*;

public Claims validateToken(String token) {
    try {
        return Jwts.parserBuilder()
            .setSigningKey(Keys.hmacShaKeyFor(
                System.getenv("JWT_SECRET").getBytes(StandardCharsets.UTF_8)))
            .requireIssuer(System.getenv("TOKEN_ISSUER"))
            .requireAudience(System.getenv("TOKEN_AUDIENCE"))
            .build()
            .parseClaimsJws(token)
            .getBody();
    } catch (ExpiredJwtException e) {
        throw new RuntimeException("Token expired");
    } catch (JwtException e) {
        throw new RuntimeException("Invalid token");
    }
}
import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError

def validate_token(token: str) -> dict:
    try:
        return jwt.decode(
            token,
            settings.JWT_SECRET,
            algorithms=["HS256"],
            issuer=settings.TOKEN_ISSUER,
            audience=settings.TOKEN_AUDIENCE,
        )
    except ExpiredSignatureError:
        raise ValueError("Token expired")
    except InvalidTokenError:
        raise ValueError("Invalid token")
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;

public ClaimsPrincipal ValidateToken(string token)
{
    var handler = new JwtSecurityTokenHandler();
    try
    {
        return handler.ValidateToken(token, new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(config["Jwt:Secret"]!)),
            ValidateIssuer = true,
            ValidIssuer = config["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience = config["Jwt:Audience"],
            ClockSkew = TimeSpan.Zero,
        }, out _);
    }
    catch (SecurityTokenExpiredException)
    {
        throw new UnauthorizedAccessException("Token expired");
    }
    catch (SecurityTokenException)
    {
        throw new UnauthorizedAccessException("Invalid token");
    }
}

Common Pitfalls and Solutions

Pitfall 1: Storing Access Tokens in localStorage

Problem: Vulnerable to XSS attacks

Solution: Use httpOnly cookies for sensitive tokens, memory for short-lived access tokens

Pitfall 2: Not Implementing PKCE

Problem: Authorization code can be intercepted on mobile/SPA

Solution: Always use PKCE for public clients

Pitfall 3: Hardcoding Redirect URIs

Problem: Inflexible, difficult to manage environments

Solution: Use environment variables

const GOOGLE_CALLBACK_URL = process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/auth/google/callback';

Pitfall 4: Mixing User Identity with Authorization Tokens

Problem: Difficult to separate user info from API access

Solution: Use ID tokens for identity, access tokens for APIs

Pitfall 5: Not Handling Token Expiration

Problem: Users get kicked out without refresh mechanism

Solution: Implement automatic token refresh

// Axios interceptor
api.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401) {
      const newToken = await refreshAccessToken();
      // Retry request with new token
    }
    return Promise.reject(error);
  }
);
// Spring RestTemplate interceptor
@Bean
public RestTemplate restTemplate() {
    RestTemplate restTemplate = new RestTemplate();
    restTemplate.getInterceptors().add((request, body, execution) -> {
        ClientHttpResponse response = execution.execute(request, body);
        if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) {
            String newToken = tokenService.refreshAccessToken(
                getCurrentUser().getRefreshToken()).accessToken();
            request.getHeaders().setBearerAuth(newToken);
            response = execution.execute(request, body);
        }
        return response;
    });
    return restTemplate;
}
# httpx event hook
async def refresh_on_401(response: httpx.Response) -> None:
    if response.status_code == 401:
        new_token = await refresh_access_token(current_user.refresh_token)
        response.request.headers["Authorization"] = f"Bearer {new_token}"
        await response.aread()

async_client = httpx.AsyncClient(event_hooks={"response": [refresh_on_401]})
// DelegatingHandler for HttpClient
public class TokenRefreshHandler : DelegatingHandler
{
    private readonly ITokenService _tokens;

    public TokenRefreshHandler(ITokenService tokens) => _tokens = tokens;

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var response = await base.SendAsync(request, ct);
        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
            var newToken = await _tokens.RefreshAccessTokenAsync(
                currentUser.RefreshToken!);
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", newToken.AccessToken);
            response = await base.SendAsync(request, ct);
        }
        return response;
    }
}

Pitfall 6: OAuth Scope Creep

Problem: Requesting unnecessary scopes damages user trust

Solution: Request minimal, specific scopes

// Good
scope: ['openid', 'profile', 'email']

// Bad
scope: ['*'] // Not standard, but principle applies

Production Checklist

Before deploying OAuth to production:

  • Environment Variables: All credentials in .env, never hardcoded
  • HTTPS Only: OAuth requires HTTPS (enforcement in Passport config)
  • Secure Cookies: httpOnly: true, secure: true, sameSite: 'lax'
  • PKCE: Enabled for all mobile and SPA clients
  • State Parameter: Generated and verified for all flows
  • Redirect URI Whitelist: Exact matches only, no wildcards
  • Token Validation: Signature and expiration verified server-side
  • Token Refresh: Automatic refresh implemented for long sessions
  • Rate Limiting: Protect OAuth endpoints from brute force
  • CORS Configuration: Restrict to allowed origins only
  • Logging: Log auth events (without sensitive data) for debugging
  • Error Handling: Don’t expose detailed error messages to users
  • Session Timeout: Implement idle session timeout (e.g., 30 minutes)
  • Account Linking: Handle existing user + new OAuth provider
  • Revocation: Implement token revocation endpoint
  • Testing: Test token expiration, refresh, revocation scenarios
  • Documentation: Document supported providers and setup process

Monitoring and Debugging

Key Metrics to Monitor

// Log OAuth events
export function logAuthEvent(event: string, userId: string, provider: string, status: string) {
  logger.info('auth_event', {
    event,
    userId,
    provider,
    status,
    timestamp: new Date().toISOString(),
  });
}

// Examples
logAuthEvent('login_start', undefined, 'google', 'initiated');
logAuthEvent('login_success', userId, 'google', 'completed');
logAuthEvent('token_refresh', userId, 'github', 'success');
logAuthEvent('token_refresh_failed', userId, 'github', 'failed');
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Component
public class AuthEventLogger {
    private static final Logger logger = LoggerFactory.getLogger(AuthEventLogger.class);

    public void logAuthEvent(String event, String userId,
            String provider, String status) {
        logger.info("auth_event event={} userId={} provider={} status={} timestamp={}",
            event, userId, provider, status,
            Instant.now().toString());
    }
}

// Examples
authEventLogger.logAuthEvent("login_start", null, "google", "initiated");
authEventLogger.logAuthEvent("login_success", userId, "google", "completed");
authEventLogger.logAuthEvent("token_refresh", userId, "github", "success");
authEventLogger.logAuthEvent("token_refresh_failed", userId, "github", "failed");
import logging
from datetime import datetime, timezone

logger = logging.getLogger(__name__)

def log_auth_event(event: str, user_id: str | None,
                   provider: str, status: str) -> None:
    logger.info("auth_event", extra={
        "event": event,
        "user_id": user_id,
        "provider": provider,
        "status": status,
        "timestamp": datetime.now(timezone.utc).isoformat(),
    })

# Examples
log_auth_event("login_start", None, "google", "initiated")
log_auth_event("login_success", user_id, "google", "completed")
log_auth_event("token_refresh", user_id, "github", "success")
log_auth_event("token_refresh_failed", user_id, "github", "failed")
public class AuthEventLogger
{
    private readonly ILogger<AuthEventLogger> _logger;

    public AuthEventLogger(ILogger<AuthEventLogger> logger) => _logger = logger;

    public void LogAuthEvent(string eventName, string? userId,
        string provider, string status)
    {
        _logger.LogInformation(
            "auth_event {Event} userId={UserId} provider={Provider} " +
            "status={Status} timestamp={Timestamp}",
            eventName, userId, provider, status,
            DateTimeOffset.UtcNow.ToString("o"));
    }
}

// Examples
authLogger.LogAuthEvent("login_start", null, "google", "initiated");
authLogger.LogAuthEvent("login_success", userId, "google", "completed");
authLogger.LogAuthEvent("token_refresh", userId, "github", "success");
authLogger.LogAuthEvent("token_refresh_failed", userId, "github", "failed");

Debugging Common Issues

Problem: “redirect_uri_mismatch”

  • Check OAuth provider configuration matches your callback URL exactly
  • Verify environment variable is set correctly
  • Check URL encoding (spaces should be %20, not +)

Problem: “invalid_grant”

  • Authorization code already used or expired
  • Code verifier doesn’t match code challenge (PKCE)
  • Invalid client credentials

Problem: User logged in but no profile data

  • Check OAuth scope configuration
  • Verify email is publicly visible (GitHub)
  • Fallback to generic user creation if data missing

Conclusion

OAuth 2.0 implementation might seem complex, but breaking it down into components makes it manageable. The key is:

  1. Choose the right flow (Authorization Code with PKCE for most cases)
  2. Understand the distinction between OAuth (authorization) and OIDC (authentication)
  3. Implement security measures (state, PKCE, secure cookies, token validation)
  4. Handle edge cases (token expiration, account linking, enterprise SSO)
  5. Monitor and test thoroughly before production

With this guide and the production-ready code examples, you’re equipped to implement secure OAuth authentication in any application.

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

Written by Palakorn Voramongkol

Software Engineer Specialist with 20+ years of experience. Writing about architecture, performance, and building production systems.

More about me

Continue Reading