Loading content...
In the early days of the web, if you wanted a third-party application to access your data from another service, you had to do something terrifying: give that application your password. Photo printing services would ask for your Facebook password. Calendar apps would request your Google credentials. This anti-pattern created massive security vulnerabilities—if any single third-party service was compromised, attackers could access every system you'd shared credentials with.
OAuth 2.0 emerged as the solution to this fundamental problem. It introduced a revolutionary concept: delegated authorization—the ability to grant limited access to your resources without sharing your credentials. Today, OAuth 2.0 is the backbone of modern authorization, powering billions of daily authorizations across Google, Microsoft, GitHub, Twitter, and virtually every major platform.
Understanding OAuth 2.0 flows isn't just academic knowledge—it's essential for any engineer building systems that interact with third-party services or expose APIs to external applications.
By the end of this page, you will understand OAuth 2.0's fundamental architecture, master each grant type's flow and use cases, recognize security considerations for each approach, and be equipped to select the appropriate flow for any authorization scenario.
OAuth 2.0 is an authorization framework (not an authentication protocol—that distinction matters and we'll address it later). It enables a third-party application to obtain limited access to an HTTP service, either on behalf of a resource owner or by allowing the third-party application to obtain access on its own behalf.
The Core Problem OAuth Solves:
Consider this scenario: You want a photo editing app to access your Google Photos. Without OAuth:
With OAuth 2.0:
OAuth 2.0's genius lies in introducing tokens as intermediaries. Instead of sharing long-lived credentials, the system issues short-lived tokens with specific scopes. Tokens can be revoked, have limited permissions, and don't expose underlying credentials. This paradigm shift fundamentally changed how we think about authorization.
The Authorization Code Flow is the most secure and commonly used OAuth 2.0 flow for applications with a secure backend server. It's the gold standard for web applications with server-side components and mobile apps using Proof Key for Code Exchange (PKCE).
Why It's the Most Secure:
This flow is designed around a critical security principle: the access token never passes through the user's browser. The browser only sees an authorization code—a short-lived, single-use credential that's exchanged for the real access token via a secure server-to-server call. This prevents token leakage through browser history, logs, or man-in-the-middle attacks on the front channel.
| Step | Actor | Action | Security Consideration |
|---|---|---|---|
| 1 | User | Clicks 'Login with Google' on the client app | User initiates flow explicitly—no silent background authorization |
| 2 | Client | Redirects to Authorization Server with client_id, redirect_uri, scope, state | state parameter prevents CSRF attacks |
| 3 | Auth Server | Authenticates user, displays consent screen | Direct user-to-provider interaction—credentials never touch client |
| 4 | User | Grants consent to requested scopes | User explicitly sees and approves permissions |
| 5 | Auth Server | Redirects back to client with authorization code | Code is short-lived (<10 min), single-use |
| 6 | Client (Backend) | Exchanges code + client_secret for access token | Server-to-server call—token never in browser |
| 7 | Auth Server | Returns access token (and optionally refresh token) | Token delivered over secure back channel |
| 8 | Client | Uses access token to call Resource Server | Token attached to API calls, typically in Authorization header |
123456789101112131415161718192021222324252627282930313233343536373839404142
// Step 1: Construct authorization URLconst authorizationUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');authorizationUrl.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID);authorizationUrl.searchParams.set('redirect_uri', 'https://myapp.com/callback');authorizationUrl.searchParams.set('response_type', 'code');authorizationUrl.searchParams.set('scope', 'openid email profile');authorizationUrl.searchParams.set('state', generateCryptoRandomState()); // CSRF protectionauthorizationUrl.searchParams.set('access_type', 'offline'); // Request refresh token // Redirect user to authorization URLres.redirect(authorizationUrl.toString()); // Step 2: Handle callback - exchange code for tokensapp.get('/callback', async (req, res) => { const { code, state } = req.query; // Verify state matches what we stored (CSRF protection) if (state !== req.session.oauthState) { return res.status(403).send('Invalid state parameter'); } // Exchange authorization code for tokens (server-to-server) const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code: code as string, client_id: process.env.GOOGLE_CLIENT_ID, client_secret: process.env.GOOGLE_CLIENT_SECRET, // Server-side only! redirect_uri: 'https://myapp.com/callback', grant_type: 'authorization_code', }), }); const tokens = await tokenResponse.json(); // tokens = { access_token, refresh_token, expires_in, token_type, id_token } // Store tokens securely - never expose to client await storeTokensSecurely(req.session.userId, tokens); res.redirect('/dashboard');});The state parameter prevents Cross-Site Request Forgery (CSRF) attacks. Without it, an attacker could trick a user into authorizing the attacker's account to be linked to the victim's session. Always generate a cryptographically random state, store it in the session, and verify it matches on callback.
Proof Key for Code Exchange (PKCE) extends the Authorization Code Flow for public clients—applications that cannot securely store a client secret, such as mobile apps, single-page applications (SPAs), and desktop applications.
The Problem with Public Clients:
Mobile apps and SPAs are distributed to user devices. Any 'secret' embedded in them can be extracted through reverse engineering or browser developer tools. Without a secret, how can the authorization server trust that the entity exchanging the code is the same entity that initiated the flow?
PKCE's Elegant Solution:
PKCE introduces a dynamic, per-request secret:
This ensures that only the entity that initiated the request can complete the token exchange, even without a static secret.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
// PKCE Implementation for SPAs/Mobile Appsimport * as crypto from 'crypto'; // Generate cryptographically random code verifier (43-128 characters)function generateCodeVerifier(): string { const buffer = crypto.randomBytes(32); return base64URLEncode(buffer);} // Generate code challenge from verifier using SHA-256async function generateCodeChallenge(verifier: string): Promise<string> { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const digest = await crypto.subtle.digest('SHA-256', data); return base64URLEncode(new Uint8Array(digest));} // Base64 URL encoding (no padding, URL-safe characters)function base64URLEncode(buffer: Uint8Array): string { return btoa(String.fromCharCode(...buffer)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, '');} // Step 1: Initiate PKCE flowasync function initiateOAuth() { const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); // Store verifier securely (will need it later) sessionStorage.setItem('pkce_code_verifier', codeVerifier); const authUrl = new URL('https://auth.example.com/authorize'); authUrl.searchParams.set('client_id', 'spa-client-id'); authUrl.searchParams.set('redirect_uri', 'https://spa.example.com/callback'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('scope', 'openid profile email'); authUrl.searchParams.set('state', generateSecureState()); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); // SHA-256 window.location.href = authUrl.toString();} // Step 2: Exchange code with verifier (no client_secret needed!)async function handleCallback(code: string) { const codeVerifier = sessionStorage.getItem('pkce_code_verifier'); sessionStorage.removeItem('pkce_code_verifier'); const response = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: 'spa-client-id', code: code, redirect_uri: 'https://spa.example.com/callback', code_verifier: codeVerifier!, // Proves we initiated the flow }), }); return response.json();}While PKCE was designed for public clients, OAuth 2.1 (the upcoming revision) recommends PKCE for ALL clients, including confidential ones with secrets. It adds defense-in-depth and protects against authorization code injection attacks even when secrets are used.
The Client Credentials Flow is used when the client application itself needs to access resources—not on behalf of a user. This is the machine-to-machine (M2M) flow, essential for backend services, daemons, microservices, and automated processes.
Key Distinction:
In other flows, a user (Resource Owner) authorizes access. In Client Credentials, the application is the Resource Owner. There's no user involvement, no consent screen, no interactive authorization—just one service authenticating to another.
Common Use Cases:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
// Client Credentials Flow - Machine-to-Machineinterface TokenResponse { access_token: string; token_type: string; expires_in: number; scope?: string;} class ServiceClient { private accessToken: string | null = null; private tokenExpiry: Date | null = null; constructor( private clientId: string, private clientSecret: string, private tokenEndpoint: string, private scope: string, ) {} // Obtain access token using client credentials async getAccessToken(): Promise<string> { // Return cached token if still valid if (this.accessToken && this.tokenExpiry && new Date() < this.tokenExpiry) { return this.accessToken; } // Request new token const response = await fetch(this.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', // Many servers support HTTP Basic Auth for client credentials 'Authorization': `Basic ${Buffer.from( `${this.clientId}:${this.clientSecret}` ).toString('base64')}`, }, body: new URLSearchParams({ grant_type: 'client_credentials', scope: this.scope, }), }); if (!response.ok) { throw new Error(`Token request failed: ${response.status}`); } const data: TokenResponse = await response.json(); // Cache token with buffer time (refresh 60s before expiry) this.accessToken = data.access_token; this.tokenExpiry = new Date(Date.now() + (data.expires_in - 60) * 1000); return this.accessToken; } // Make authenticated API call async callApi(endpoint: string, options: RequestInit = {}): Promise<Response> { const token = await this.getAccessToken(); return fetch(endpoint, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${token}`, }, }); }} // Usageconst paymentService = new ServiceClient( process.env.PAYMENT_SERVICE_CLIENT_ID!, process.env.PAYMENT_SERVICE_CLIENT_SECRET!, 'https://auth.internal.example.com/oauth/token', 'payment:read payment:write',); // Calls are automatically authenticatedconst transactions = await paymentService.callApi( 'https://payment.internal.example.com/api/transactions');Client credentials (client_id + client_secret) are essentially the service's username and password. They must be stored securely using environment variables, secrets managers (AWS Secrets Manager, HashiCorp Vault), or cloud provider secret storage. Never commit secrets to version control or embed them in container images.
The Implicit Flow was designed for browser-based JavaScript applications before CORS was widely supported. It returned access tokens directly in the URL fragment, skipping the code exchange step. This flow is now deprecated and should NOT be used for new implementations.
Why It Was Created:
In the early days of OAuth 2.0 (circa 2012), browsers had severe limitations:
Why It's Deprecated:
The Implicit Flow has fundamental security weaknesses:
OAuth 2.0 Security Best Current Practice (RFC 9700) explicitly recommends against using the Implicit Flow. All major identity providers (Google, Microsoft, Auth0, Okta) now recommend Authorization Code Flow with PKCE for SPAs instead. If you encounter legacy systems using Implicit Flow, plan a migration to PKCE.
| Aspect | Implicit Flow | Auth Code + PKCE |
|---|---|---|
| Token Exposure | Token in URL fragment—high exposure risk | Token returned via secure POST—never in URL |
| Refresh Tokens | Not supported—constant re-authentication | Fully supported—silent token refresh |
| Client Auth | None possible—tokens can be intercepted | PKCE proves client identity dynamically |
| Token Lifetime | Must be very short (<1 hour) | Can be longer with refresh token rotation |
| Security Status | Deprecated, known vulnerabilities | Current best practice, actively maintained |
Migration Path:
If you have an SPA using Implicit Flow:
response_type=token to response_type=codeModern libraries like @auth0/auth0-spa-js, oidc-client-ts, and @azure/msal-browser handle PKCE automatically.
The Resource Owner Password Credentials (ROPC) Flow allows the client to directly collect the user's username and password and exchange them for tokens. This flow exists for very specific legacy migration scenarios and should be avoided in new designs.
When It Might Be Acceptable:
Why It's Problematic:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// ROPC Flow - ONLY for legacy migration scenarios// ⚠️ NOT RECOMMENDED for new implementations async function legacyPasswordLogin(username: string, password: string) { // WARNING: User credentials are directly handled by the client // This creates security and trust concerns 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: 'password', username: username, password: password, client_id: 'legacy-app-client-id', client_secret: 'legacy-app-client-secret', // If confidential client scope: 'openid profile email', }), }); if (!response.ok) { const error = await response.json(); throw new Error(`Authentication failed: ${error.error_description}`); } return response.json(); // { access_token, refresh_token, expires_in, ... }} // Migration example: Wrapping legacy login with OAuthclass MigrationAuthService { // Phase 1: Use ROPC during migration async legacyLogin(username: string, password: string) { return legacyPasswordLogin(username, password); } // Phase 2: Offer modern OAuth flow as alternative async modernLogin() { // Redirect to Authorization Code + PKCE flow window.location.href = buildPKCEAuthUrl(); } // Phase 3: Deprecate legacy, use modern flow only async login() { return this.modernLogin(); }}The upcoming OAuth 2.1 specification completely removes the Resource Owner Password Credentials grant type. If you're designing new systems or modernizing existing ones, avoid ROPC entirely. Use Authorization Code + PKCE for interactive scenarios or Device Flow for input-constrained devices.
The Device Authorization Flow (also called Device Code Flow) is designed for devices with limited input capabilities—smart TVs, game consoles, IoT devices, and CLI tools. These devices either lack browsers or have input methods that make typing credentials painful.
The User Experience:
Why This Flow is Brilliant:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
// Device Authorization Flow Implementationinterface DeviceCodeResponse { device_code: string; user_code: string; verification_uri: string; verification_uri_complete?: string; // URI with code pre-filled expires_in: number; interval: number; // Polling interval in seconds} class DeviceAuth { constructor( private clientId: string, private authServer: string, private scope: string, ) {} // Step 1: Request device and user codes async initiateDeviceAuthorization(): Promise<DeviceCodeResponse> { const response = await fetch(`${this.authServer}/device/code`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: this.clientId, scope: this.scope, }), }); return response.json(); } // Step 2: Display to user displayUserInstructions(deviceCode: DeviceCodeResponse): void { console.log('\n' + '='.repeat(60)); console.log('To sign in, visit: ' + deviceCode.verification_uri); console.log('And enter code: ' + deviceCode.user_code); console.log('='.repeat(60) + '\n'); // If QR code support exists, display it if (deviceCode.verification_uri_complete) { generateQRCode(deviceCode.verification_uri_complete); } } // Step 3: Poll for authorization async pollForToken(deviceCode: string, interval: number, timeout: number) { const startTime = Date.now(); while (Date.now() - startTime < timeout * 1000) { await sleep(interval * 1000); const response = await fetch(`${this.authServer}/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: deviceCode, client_id: this.clientId, }), }); const data = await response.json(); if (data.access_token) { return data; // Authorization successful! } // Handle pending states switch (data.error) { case 'authorization_pending': // User hasn't authorized yet, keep polling continue; case 'slow_down': // Server says slow down, increase interval interval += 5; continue; case 'access_denied': throw new Error('User denied authorization'); case 'expired_token': throw new Error('Device code expired, restart flow'); default: throw new Error(`Unknown error: ${data.error}`); } } throw new Error('Authorization timeout'); } // Full flow async authorize() { const deviceCode = await this.initiateDeviceAuthorization(); this.displayUserInstructions(deviceCode); return this.pollForToken( deviceCode.device_code, deviceCode.interval, deviceCode.expires_in, ); }} // CLI Usageconst auth = new DeviceAuth( 'cli-tool-client-id', 'https://auth.example.com/oauth', 'openid profile email offline_access',); const tokens = await auth.authorize();console.log('Successfully authenticated!');Device Flow is used by YouTube on Smart TVs, Netflix on game consoles, GitHub CLI, Azure CLI, Google Cloud CLI, and AWS SSO. Whenever you see 'sign in on another device,' it's likely Device Authorization Flow in action.
Selecting the appropriate OAuth 2.0 flow is a critical architectural decision that impacts security, user experience, and implementation complexity. Here's a decision framework used by experienced engineers:
| Scenario | Recommended Flow | Rationale |
|---|---|---|
| Web app with secure backend | Authorization Code | Backend can securely store client_secret; token never exposed to browser |
| Single-Page Application (SPA) | Auth Code + PKCE | No secret storage possible; PKCE provides dynamic client authentication |
| Native mobile app | Auth Code + PKCE | Same as SPA—public client requiring PKCE protection |
| Service-to-service (M2M) | Client Credentials | No user context; application authenticates itself |
| Smart TV / IoT device | Device Authorization | Limited input capability; authenticate on secondary device |
| CLI tool (user context) | Device Authorization | Better UX than browser redirect; authenticate in preferred browser |
| CLI tool (automation) | Client Credentials | Non-interactive; service identity rather than user identity |
| Legacy system migration | ROPC (temporary) | Transition path only; plan migration to standard flows |
When in doubt, start with Authorization Code Flow + PKCE. It's secure for all client types, supports refresh tokens, works with modern identity providers, and is the direction OAuth is heading with OAuth 2.1. Add the client_secret for confidential clients as an additional security layer.
We've conducted a comprehensive exploration of OAuth 2.0's authorization flows. Let's consolidate the critical knowledge:
What's Next:
Now that we understand how to obtain tokens, we need to understand the tokens themselves. The next page explores access tokens and refresh tokens—their structure, lifecycle management, secure storage, and the critical practice of refresh token rotation.
You now have comprehensive knowledge of OAuth 2.0 authorization flows. You can select the appropriate flow for any scenario, implement it securely, and explain the security rationale to stakeholders. Next, we'll dive deep into token management—the critical component that makes OAuth actually work in production systems.