正在加载,请稍候…

OAuth 2.0 and OpenID Connect Complete Guide: How Modern Auth Actually Works

Deep dive into OAuth 2.0 flows and OpenID Connect (OIDC): authorization code, PKCE, implicit, client credentials, and how JWTs fit in. With real examples.

Why "Login with Google" Is More Complex Than It Looks

Every time you click "Continue with Google" or "Sign in with GitHub," a carefully choreographed handshake happens behind the scenes. OAuth 2.0 is the authorization protocol that makes this work, and OpenID Connect (OIDC) is the identity layer built on top of it. Together they power nearly every modern authentication flow on the web.

This guide cuts through the confusion. OAuth and OIDC are frequently misunderstood — even by developers who use them daily.

OAuth 2.0: Authorization, Not Authentication

The single most important thing to understand: OAuth 2.0 is an authorization protocol, not an authentication protocol. It was designed to let a user grant a third-party application limited access to their resources — without sharing their password.

The classic example: a photo editing app wants to access your Google Photos. You don't want to give the app your Google password. OAuth lets Google issue the app a time-limited access token with read-only access to photos.

The Four OAuth 2.0 Roles

Role Description Example
Resource Owner The user who owns the data You
Client The app requesting access Photo editor app
Authorization Server Issues tokens after auth Google's auth server
Resource Server Hosts the protected data Google Photos API

The Authorization Code Flow (Most Common)

This is the flow you should use for web applications and mobile apps with a backend.

User → Client App → Authorization Server → User Login → Redirect with Code
→ Client exchanges Code for Tokens → Client calls Resource Server with Token

Step by step:

# Step 1: Client redirects user to authorization server
GET https://accounts.google.com/o/oauth2/v2/auth?
  response_type=code&
  client_id=YOUR_CLIENT_ID&
  redirect_uri=https://yourapp.com/callback&
  scope=openid%20email%20profile&
  state=RANDOM_STATE_VALUE

# Step 2: User authenticates and approves
# Authorization server redirects back:
GET https://yourapp.com/callback?code=AUTH_CODE&state=RANDOM_STATE_VALUE

# Step 3: Exchange code for tokens (server-to-server, never in browser!)
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://yourapp.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET

# Response:
{
  "access_token": "ya29.a0AfH6...",
  "id_token": "eyJhbGciOiJSUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 3599,
  "refresh_token": "1//0gLh..."
}

The state parameter is critical — it prevents CSRF attacks. Generate a random value, store it in session, and verify it matches when the callback arrives.

PKCE: Authorization Code Flow for SPAs and Mobile

Single-page apps and mobile apps cannot safely store a client_secret (it would be visible to users). PKCE (Proof Key for Code Exchange, pronounced "pixy") solves this.

// Generate PKCE code verifier (random string)
const codeVerifier = generateRandomString(64);
// sha256("verifier") → base64url
const codeChallenge = base64url(sha256(codeVerifier));

// Step 1: Authorization request includes code_challenge
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// ... other params

// Step 2: Token exchange includes code_verifier
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authCode,
    code_verifier: codeVerifier, // proves we initiated the request
    // NO client_secret needed!
  })
});

PKCE works because an attacker who intercepts the authorization code cannot exchange it for tokens without the code_verifier that only the legitimate client has.

Other OAuth 2.0 Grant Types

Client Credentials (Machine-to-Machine)

For server-to-server API calls where there's no user involved:

// Your service authenticates directly with its own credentials
const response = await fetch('https://api.example.com/oauth/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: process.env.CLIENT_ID,
    client_secret: process.env.CLIENT_SECRET,
    scope: 'read:data write:data'
  })
});

const { access_token } = await response.json();

// Use token to call API
await fetch('https://api.example.com/data', {
  headers: { Authorization: `Bearer ${access_token}` }
});

Refresh Token Flow

Access tokens are short-lived. When they expire, use a refresh token:

async function getValidToken(refreshToken) {
  const response = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET
    })
  });
  
  const data = await response.json();
  if (!response.ok) {
    throw new Error('Refresh failed: ' + data.error);
  }
  
  return data.access_token;
}

OpenID Connect: Adding Authentication to OAuth

OAuth only says "here's an access token to call this API." It says nothing about who the user is. OpenID Connect adds an ID Token — a JWT containing the user's identity claims.

When you add openid to your OAuth scope, the authorization server returns an ID token alongside the access token:

// ID Token is a JWT with claims about the user
// Decode it (but always verify the signature first!)
const idTokenPayload = {
  "iss": "https://accounts.google.com",  // Issuer
  "sub": "110169484474386276334",          // Subject (user ID)
  "aud": "YOUR_CLIENT_ID",                 // Audience
  "exp": 1516239022,                       // Expiry
  "iat": 1516235422,                       // Issued at
  "nonce": "abc123",                       // Replay protection
  
  // Standard OIDC claims with profile scope:
  "name": "Jane Doe",
  "email": "jane@example.com",
  "picture": "https://...",
  "email_verified": true
}

Verifying the ID Token

Never trust an ID token without verifying it:

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://accounts.google.com/.well-known/openid-configuration')
  // Jose automatically fetches the actual jwks_uri from the discovery doc
);

async function verifyIdToken(idToken) {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: 'https://accounts.google.com',
    audience: YOUR_CLIENT_ID,
  });
  
  // Check nonce to prevent replay attacks
  if (payload.nonce !== sessionNonce) {
    throw new Error('Nonce mismatch');
  }
  
  return payload; // Trusted user info
}

OIDC Discovery Document

Every OIDC provider exposes a discovery document at /.well-known/openid-configuration:

curl https://accounts.google.com/.well-known/openid-configuration | jq .

{
  "issuer": "https://accounts.google.com",
  "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
  "token_endpoint": "https://oauth2.googleapis.com/token",
  "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
  "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
  "scopes_supported": ["openid", "email", "profile"],
  "response_types_supported": ["code", "token", "id_token"],
  ...
}

Use the discovery document rather than hardcoding endpoints — they can change.

Common Security Mistakes

1. Storing tokens in localStorage

// ❌ Vulnerable to XSS attacks
localStorage.setItem('access_token', token);

// ✅ Use httpOnly cookies (not accessible from JavaScript)
// Or store in memory and use refresh tokens in httpOnly cookies

2. Not validating state parameter

// ❌ CSRF vulnerability
router.get('/callback', async (req) => {
  const { code } = req.query;
  await exchangeCode(code); // state not checked!
});

// ✅ Always verify state
router.get('/callback', async (req) => {
  const { code, state } = req.query;
  if (state !== req.session.oauthState) {
    return res.status(400).send('State mismatch');
  }
  await exchangeCode(code);
});

3. Using implicit flow (deprecated) The implicit flow returned tokens directly in URL fragments — they ended up in browser history and server logs. Always use authorization code + PKCE instead.

4. Not checking token expiry

function isTokenExpired(token) {
  const { exp } = JSON.parse(atob(token.split('.')[1]));
  return Date.now() >= exp * 1000;
}

Real Implementation with Passport.js

import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  callbackURL: '/auth/google/callback',
  scope: ['openid', 'email', 'profile']
}, async (accessToken, refreshToken, profile, done) => {
  // profile contains verified user info from Google
  let user = await User.findOne({ googleId: profile.id });
  
  if (!user) {
    user = await User.create({
      googleId: profile.id,
      email: profile.emails[0].value,
      name: profile.displayName,
    });
  }
  
  return done(null, user);
}));

// Routes
app.get('/auth/google', passport.authenticate('google'));

app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login' }),
  (req, res) => res.redirect('/dashboard')
);

OAuth vs API Keys vs Sessions

Approach Best For Security User Experience
OAuth 2.0 Third-party access, SSO High (limited scope) Seamless
API Keys Server-to-server, M2M Medium (no expiry) Simple
Session Cookies Own web app High (httpOnly) Transparent
JWT (stateless) Microservices Medium (hard to revoke) Scalable

→ Use the JWT Parser to decode and inspect OAuth ID tokens and access tokens.