How to Set Up OAuth Authentication Securely

Share
How-To Guide

How to Set Up OAuth Authentication Securely

Social login done right with Google, GitHub, and more

TL;DR

TL;DR (30 minutes): Use Authorization Code flow with PKCE (not implicit flow). Always validate the state parameter. Verify tokens on the server side, not client. Use exact redirect URI matching. Store OAuth tokens encrypted. Link accounts by verified email only after confirming email ownership.

Prerequisites:

  • OAuth provider account (Google, GitHub, etc.)
  • HTTPS-enabled domain
  • Database for storing user accounts

Why This Matters

OAuth lets users sign in with existing accounts (Google, GitHub, etc.) instead of creating new passwords. But improper implementation leads to account takeover vulnerabilities. Common mistakes include missing state validation, improper token handling, and insecure account linking.

Step-by-Step Guide

1

Register your OAuth application

Create credentials with your OAuth provider:

# Google Cloud Console
1. Go to console.cloud.google.com
2. Create project → APIs & Services → Credentials
3. Create OAuth 2.0 Client ID
4. Add authorized redirect URIs:
   - https://yourapp.com/api/auth/callback/google
   - http://localhost:3000/api/auth/callback/google (dev)

# GitHub
1. Go to github.com/settings/developers
2. New OAuth App
3. Set callback URL:
   - https://yourapp.com/api/auth/callback/github

# Store credentials securely
GOOGLE_CLIENT_ID=xxx
GOOGLE_CLIENT_SECRET=xxx
GITHUB_CLIENT_ID=xxx
GITHUB_CLIENT_SECRET=xxx
2

Implement authorization with PKCE

import crypto from 'crypto';

// Generate PKCE challenge
function generatePKCE() {
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');

  return { verifier, challenge };
}

// Generate state parameter (prevents CSRF)
function generateState() {
  return crypto.randomBytes(32).toString('hex');
}

// Start OAuth flow
async function startOAuth(req, res, provider) {
  const { verifier, challenge } = generatePKCE();
  const state = generateState();

  // Store in session for validation later
  req.session.oauthState = state;
  req.session.oauthVerifier = verifier;
  req.session.oauthProvider = provider;

  const params = new URLSearchParams({
    client_id: process.env[`${provider.toUpperCase()}_CLIENT_ID`],
    redirect_uri: `${process.env.APP_URL}/api/auth/callback/${provider}`,
    response_type: 'code',
    scope: getScopes(provider),
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256'
  });

  const authUrl = getAuthUrl(provider);
  res.redirect(`${authUrl}?${params}`);
}

function getScopes(provider) {
  switch (provider) {
    case 'google': return 'openid email profile';
    case 'github': return 'read:user user:email';
    default: return 'openid email profile';
  }
}

function getAuthUrl(provider) {
  switch (provider) {
    case 'google': return 'https://accounts.google.com/o/oauth2/v2/auth';
    case 'github': return 'https://github.com/login/oauth/authorize';
  }
}
3

Handle the callback securely

async function handleCallback(req, res) {
  const { code, state, error } = req.query;
  const provider = req.params.provider;

  // Check for OAuth errors
  if (error) {
    return res.redirect('/login?error=oauth_denied');
  }

  // CRITICAL: Validate state parameter
  if (!state || state !== req.session.oauthState) {
    return res.redirect('/login?error=invalid_state');
  }

  // Validate provider matches
  if (provider !== req.session.oauthProvider) {
    return res.redirect('/login?error=provider_mismatch');
  }

  try {
    // Exchange code for tokens
    const tokens = await exchangeCodeForTokens(
      provider,
      code,
      req.session.oauthVerifier
    );

    // Get user info from provider
    const oauthUser = await getOAuthUserInfo(provider, tokens.access_token);

    // Find or create user in your database
    const user = await findOrCreateUser(oauthUser, provider, tokens);

    // Clear OAuth session data
    delete req.session.oauthState;
    delete req.session.oauthVerifier;
    delete req.session.oauthProvider;

    // Create your app's session
    await createUserSession(req, res, user);

    res.redirect('/dashboard');
  } catch (error) {
    console.error('OAuth callback error:', error);
    res.redirect('/login?error=oauth_failed');
  }
}

async function exchangeCodeForTokens(provider, code, verifier) {
  const tokenUrl = provider === 'google'
    ? 'https://oauth2.googleapis.com/token'
    : 'https://github.com/login/oauth/access_token';

  const response = await fetch(tokenUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Accept': 'application/json'
    },
    body: new URLSearchParams({
      client_id: process.env[`${provider.toUpperCase()}_CLIENT_ID`],
      client_secret: process.env[`${provider.toUpperCase()}_CLIENT_SECRET`],
      code,
      grant_type: 'authorization_code',
      redirect_uri: `${process.env.APP_URL}/api/auth/callback/${provider}`,
      code_verifier: verifier
    })
  });

  const data = await response.json();

  if (data.error) {
    throw new Error(data.error_description || data.error);
  }

  return data;
}
4

Get and verify user info

async function getOAuthUserInfo(provider, accessToken) {
  let userInfo;

  if (provider === 'google') {
    const response = await fetch(
      'https://www.googleapis.com/oauth2/v2/userinfo',
      { headers: { Authorization: `Bearer ${accessToken}` } }
    );
    userInfo = await response.json();

    return {
      providerId: userInfo.id,
      email: userInfo.email,
      emailVerified: userInfo.verified_email,
      name: userInfo.name,
      avatar: userInfo.picture
    };
  }

  if (provider === 'github') {
    // Get user profile
    const userResponse = await fetch('https://api.github.com/user', {
      headers: { Authorization: `Bearer ${accessToken}` }
    });
    const user = await userResponse.json();

    // Get user emails (GitHub may not include email in profile)
    const emailsResponse = await fetch('https://api.github.com/user/emails', {
      headers: { Authorization: `Bearer ${accessToken}` }
    });
    const emails = await emailsResponse.json();

    // Find primary verified email
    const primaryEmail = emails.find(e => e.primary && e.verified);

    return {
      providerId: user.id.toString(),
      email: primaryEmail?.email,
      emailVerified: primaryEmail?.verified || false,
      name: user.name || user.login,
      avatar: user.avatar_url
    };
  }
}
5
async function findOrCreateUser(oauthUser, provider, tokens) {
  // Check if OAuth account already linked
  let oauthAccount = await prisma.oAuthAccount.findUnique({
    where: {
      provider_providerId: {
        provider,
        providerId: oauthUser.providerId
      }
    },
    include: { user: true }
  });

  if (oauthAccount) {
    // Update tokens
    await prisma.oAuthAccount.update({
      where: { id: oauthAccount.id },
      data: {
        accessToken: encrypt(tokens.access_token),
        refreshToken: tokens.refresh_token ? encrypt(tokens.refresh_token) : null
      }
    });
    return oauthAccount.user;
  }

  // IMPORTANT: Only link to existing account if email is verified
  if (oauthUser.email && oauthUser.emailVerified) {
    const existingUser = await prisma.user.findUnique({
      where: { email: oauthUser.email }
    });

    if (existingUser) {
      // Link OAuth account to existing user
      await prisma.oAuthAccount.create({
        data: {
          provider,
          providerId: oauthUser.providerId,
          userId: existingUser.id,
          accessToken: encrypt(tokens.access_token),
          refreshToken: tokens.refresh_token ? encrypt(tokens.refresh_token) : null
        }
      });
      return existingUser;
    }
  }

  // Create new user
  const user = await prisma.user.create({
    data: {
      email: oauthUser.email,
      emailVerified: oauthUser.emailVerified ? new Date() : null,
      name: oauthUser.name,
      avatar: oauthUser.avatar,
      oauthAccounts: {
        create: {
          provider,
          providerId: oauthUser.providerId,
          accessToken: encrypt(tokens.access_token),
          refreshToken: tokens.refresh_token ? encrypt(tokens.refresh_token) : null
        }
      }
    }
  });

  return user;
}
6

Schema for OAuth accounts

// Prisma schema
model User {
  id            String    @id @default(cuid())
  email         String?   @unique
  emailVerified DateTime?
  name          String?
  avatar        String?
  oauthAccounts OAuthAccount[]
  sessions      Session[]
}

model OAuthAccount {
  id           String  @id @default(cuid())
  provider     String  // 'google', 'github', etc.
  providerId   String  // ID from the OAuth provider
  userId       String
  user         User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  accessToken  String  // Encrypted
  refreshToken String? // Encrypted

  @@unique([provider, providerId])
  @@index([userId])
}

OAuth Security Mistakes to Avoid:

  • Never skip state validation - it prevents CSRF attacks
  • Never use implicit flow for web apps - use authorization code with PKCE
  • Never trust unverified emails for account linking - attackers can register with victim's email
  • Never expose client secrets to the frontend
  • Never use wildcard redirect URIs - exact match only
  • Always verify tokens on your server, not the client

How to Verify It Worked

  1. Test state validation: Modify the state parameter in callback URL - should fail
  2. Test redirect URI: Try different redirect URIs - should fail
  3. Test account linking: Create accounts with same email different providers
  4. Inspect tokens: Verify access tokens are encrypted in database

Common Errors & Troubleshooting

Error: "redirect_uri_mismatch"

The redirect URI doesn't match what's registered. Check exact match including trailing slashes.

Error: "invalid_grant"

The authorization code was already used or expired. Codes are single-use.

State mismatch

Session was lost between redirect and callback. Check session configuration and cookie settings.

Email not returned

Some providers don't return email in profile. Request email scope and fetch from separate endpoint (like GitHub).

Should I store OAuth refresh tokens?

Only if you need to access the provider's API on behalf of the user (like accessing their Google Drive). For simple authentication, you don't need to store tokens after creating the session.

How do I handle account linking for existing users?

If a user is logged in and wants to add another OAuth provider, link directly to their account. If not logged in, only link by email if the email is verified by the OAuth provider.

Why PKCE if I'm using server-side code?

PKCE adds defense in depth. It protects against authorization code interception, which can happen even in server-side flows. Modern OAuth best practices recommend PKCE for all clients.

Related guides:NextAuth Setup · Session Management · Magic Links

How-To Guides

How to Set Up OAuth Authentication Securely