DEV Community

Atlas Whoff
Atlas Whoff

Posted on

OAuth 2.0 Flows Demystified: Authorization Code, PKCE, and Client Credentials

OAuth 2.0 Is Not Authentication

OAuth 2.0 is an authorization framework. It answers: "Can application X access resource Y on behalf of user Z?"

OpenID Connect (OIDC) layers authentication on top: "Who is this user?"

Most developers use both without realizing it.

The Four Flows

1. Authorization Code Flow (Web Apps)

The standard flow for web applications with a backend.

Browser → Your App → GitHub/Google ("Allow access?") → Your App (with code) → Exchange code for token
Enter fullscreen mode Exit fullscreen mode
// Step 1: Redirect user to provider
app.get('/auth/github', (req, res) => {
  const state = generateRandomString(16); // CSRF protection
  req.session.oauthState = state;

  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID!,
    redirect_uri: `${process.env.APP_URL}/auth/github/callback`,
    scope: 'read:user user:email',
    state,
  });

  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});

// Step 2: Handle callback with code
app.get('/auth/github/callback', async (req, res) => {
  const { code, state } = req.query;

  // Verify state to prevent CSRF
  if (state !== req.session.oauthState) {
    return res.status(400).send('Invalid state');
  }

  // Exchange code for token (server-to-server)
  const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
    body: JSON.stringify({
      client_id: process.env.GITHUB_CLIENT_ID,
      client_secret: process.env.GITHUB_CLIENT_SECRET, // never exposed to browser
      code,
    }),
  }).then(r => r.json());

  const { access_token } = tokenResponse;

  // Fetch user info
  const user = await fetch('https://api.github.com/user', {
    headers: { Authorization: `Bearer ${access_token}` },
  }).then(r => r.json());

  // Create or find user in your DB
  const dbUser = await upsertUser({ githubId: user.id, email: user.email, name: user.name });

  // Create your own session
  req.session.userId = dbUser.id;
  res.redirect('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

2. PKCE Flow (SPAs and Mobile)

Same as Authorization Code but without a client secret (safe for public clients).

// Generate code verifier and challenge
function generatePKCE() {
  const verifier = generateRandomString(64);
  const challenge = base64url(sha256(verifier));
  return { verifier, challenge };
}

// Step 1: Redirect with challenge
const { verifier, challenge } = generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier);

const params = new URLSearchParams({
  client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI,
  response_type: 'code',
  scope: 'openid profile email',
  code_challenge: challenge,
  code_challenge_method: 'S256',
});

window.location.href = `https://provider.com/auth?${params}`;

// Step 2: Exchange code + verifier (no secret needed)
const response = await fetch('https://provider.com/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: CLIENT_ID,
    code,
    code_verifier: sessionStorage.getItem('pkce_verifier')!,
    redirect_uri: REDIRECT_URI,
  }),
});
Enter fullscreen mode Exit fullscreen mode

3. Client Credentials (Machine-to-Machine)

No user involved. Service authenticates as itself.

async function getServiceToken(): Promise<string> {
  const response = await fetch('https://auth.example.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: process.env.SERVICE_CLIENT_ID!,
      client_secret: process.env.SERVICE_CLIENT_SECRET!,
      audience: 'https://api.example.com',
    }),
  }).then(r => r.json());

  return response.access_token;
}

// Use in service-to-service calls
const token = await getServiceToken();
const data = await fetch('https://api.example.com/data', {
  headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());
Enter fullscreen mode Exit fullscreen mode

Using NextAuth.js (The Easy Path)

// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET! }),
    Google({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! }),
  ],
  callbacks: {
    async signIn({ user, account }) {
      // Custom logic: block certain domains, etc.
      return true;
    },
    async session({ session, token }) {
      session.user.id = token.sub!;
      return session;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

NextAuth handles the code exchange, token storage, session management, and CSRF protection automatically. Use it unless you have a specific reason to implement OAuth yourself.

The Key Security Rules

  1. Always validate state — prevents CSRF attacks
  2. Never expose client secrets to browsers — use Authorization Code + PKCE for SPAs
  3. Use short-lived access tokens — 15 minutes to 1 hour
  4. Store refresh tokens securely — httpOnly cookies, not localStorage
  5. Always use HTTPS — tokens in query params are logged by servers

NextAuth.js with GitHub, Google, and email magic links pre-configured: Whoff Agents AI SaaS Starter Kit.

Top comments (0)