Lesson 6.3: OAuth Basics
Duration: 55 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Understand what OAuth is and the problem it solves
- Explain the OAuth 2.0 authorization flow conceptually
- Identify the roles and components in OAuth
- Understand the difference between authentication and authorization
- Recognize common OAuth flows used in different applications
Introduction
Imagine you want to use an app that posts to Twitter on your behalf. The app needs access to your Twitter account, but you definitely do not want to give them your Twitter password. How can the app post for you without knowing your password?
This is the problem OAuth solves. It allows users to grant limited access to their accounts on one service to another application, without sharing passwords.
Traditional (Dangerous):
You → Give password to App → App logs in as you
OAuth (Safe):
You → Tell Twitter "Let this app post for me" → Twitter gives app a token
The Problem OAuth Solves
Before OAuth
In the early days of the web, if an app needed access to your data on another service, you had to give it your password:
Problems with sharing passwords:
1. The app has full access to your account
2. You can't limit what the app can do
3. To revoke access, you must change your password
4. If the app is compromised, your password leaks
5. The app might store your password insecurely
With OAuth
OAuth introduces a middleman - the authorization server:
OAuth Flow:
1. App asks Twitter: "Can I access this user's tweets?"
2. Twitter asks you: "Do you want to let this app access your tweets?"
3. You say: "Yes, but only for reading, not posting"
4. Twitter gives the app a token with read-only access
5. App uses the token - never sees your password
OAuth Terminology
Understanding OAuth requires knowing these key terms:
Roles
| Role | Description | Example |
|---|---|---|
| Resource Owner | The user who owns the data | You |
| Client | The application requesting access | A photo editing app |
| Authorization Server | Issues tokens after user consent | accounts.google.com |
| Resource Server | Hosts the protected data (API) | api.google.com |
Components
| Component | Description |
|---|---|
| Authorization Code | Temporary code exchanged for tokens |
| Access Token | Credential used to access resources |
| Refresh Token | Long-lived token to get new access tokens |
| Scope | Permissions being requested |
| Redirect URI | Where user is sent after authorization |
| Client ID | Public identifier for the application |
| Client Secret | Secret key known only to app and auth server |
Authentication vs Authorization
These terms are often confused but mean different things:
Authentication
"Who are you?" - Verifying identity
// Authentication: Proving you are John
login(username: "john", password: "secret123");
// Server: "Yes, this is John"
Authorization
"What can you do?" - Granting permissions
// Authorization: What can John access?
const permissions = {
readPosts: true,
writePosts: true,
deleteAccount: false,
adminPanel: false,
};
OAuth is Primarily About Authorization
OAuth lets you authorize an application to access your resources. The application does not authenticate as you - it gets permission to act on your behalf.
"Login with Google" is actually:
1. Authentication: Google verifies you are you
2. Authorization: You permit the app to see your email/name
The OAuth 2.0 Authorization Code Flow
This is the most common and secure OAuth flow, used by web applications.
Step-by-Step
┌─────────┐ ┌─────────────────┐
│ User │ │ Application │
└────┬────┘ └────────┬────────┘
│ │
│ 1. Click "Login with GitHub" │
│ ─────────────────────────────────────────────────>
│ │
│ 2. Redirect to GitHub authorization URL │
│ <─────────────────────────────────────────────────
│ │
│ ┌─────────────┴───────────┐
│ │ GitHub (Auth Server) │
│ └─────────────┬───────────┘
│ │
│ 3. GitHub shows consent screen │
│ <─────────────────────────────────────────────────
│ │
│ 4. User approves access │
│ ─────────────────────────────────────────────────>
│ │
│ 5. Redirect back to app with authorization code │
│ <─────────────────────────────────────────────────
│ │
│ ┌─────────────┴───────────┐
│ │ Application │
│ └─────────────┬───────────┘
│ │
│ 6. App exchanges code for tokens (server-side) │
│ │
│ 7. App receives access token and refresh token │
│ │
│ 8. App can now make API calls on user's behalf │
│ │
Step 1: Initiate Authorization
The app creates an authorization URL and redirects the user:
function buildAuthorizationUrl(
authEndpoint: string,
clientId: string,
redirectUri: string,
scopes: string[],
state: string
): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
state: state, // Random string to prevent CSRF
});
return `${authEndpoint}?${params.toString()}`;
}
// Example: GitHub OAuth
const authUrl = buildAuthorizationUrl(
'https://github.com/login/oauth/authorize',
'your-client-id',
'https://yourapp.com/callback',
['read:user', 'user:email'],
'random-state-string-12345'
);
// Redirect user to this URL
// window.location.href = authUrl;
Step 2: User Grants Permission
GitHub shows the user a consent screen:
┌────────────────────────────────────────────┐
│ │
│ Authorize YourApp │
│ │
│ YourApp wants to access your GitHub │
│ account │
│ │
│ This will allow YourApp to: │
│ ✓ Read your profile information │
│ ✓ Read your email address │
│ │
│ [ Authorize YourApp ] [ Cancel ] │
│ │
└────────────────────────────────────────────┘
Step 3: Receive Authorization Code
After approval, GitHub redirects back to your app with a code:
https://yourapp.com/callback?code=abc123xyz&state=random-state-string-12345
// Parse the callback URL
function handleOAuthCallback(url: string): { code: string; state: string } {
const urlObj = new URL(url);
const code = urlObj.searchParams.get('code');
const state = urlObj.searchParams.get('state');
if (!code) {
throw new Error('Authorization code not found');
}
if (!state) {
throw new Error('State parameter not found');
}
return { code, state };
}
// Verify state matches what you sent (prevents CSRF attacks)
function verifyState(received: string, expected: string): void {
if (received !== expected) {
throw new Error('State mismatch - possible CSRF attack');
}
}
Step 4: Exchange Code for Tokens
The authorization code is exchanged for tokens (server-side only):
interface TokenResponse {
access_token: string;
token_type: string;
scope: string;
refresh_token?: string;
expires_in?: number;
}
async function exchangeCodeForTokens(
tokenEndpoint: string,
code: string,
clientId: string,
clientSecret: string,
redirectUri: string
): Promise<TokenResponse> {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
}).toString(),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return response.json();
}
// Example: GitHub token exchange
const tokens = await exchangeCodeForTokens(
'https://github.com/login/oauth/access_token',
'abc123xyz', // The code from callback
'your-client-id',
'your-client-secret',
'https://yourapp.com/callback'
);
console.log('Access token:', tokens.access_token);
Step 5: Use the Access Token
Now you can make API requests on behalf of the user:
async function getGitHubUser(accessToken: string) {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json',
},
});
return response.json();
}
const user = await getGitHubUser(tokens.access_token);
console.log(`Hello, ${user.login}!`);
Scopes: Limiting Permissions
Scopes define what the application can access. Users can see exactly what they are granting.
Example Scopes
// GitHub scopes
const scopes = [
'read:user', // Read user profile
'user:email', // Access email addresses
'repo', // Full access to repositories
'repo:status', // Access commit status
'delete_repo', // Delete repositories (dangerous!)
];
// Google scopes
const googleScopes = [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/drive.file',
];
Best Practice: Request Minimum Scopes
// BAD: Requesting everything
const scopes = ["repo", "user", "gist", "delete_repo", "admin:org"];
// GOOD: Only what you need
const scopes = ["read:user", "user:email"];
Other OAuth Flows
Implicit Flow (Legacy)
For browser-only apps, tokens returned directly in URL. Deprecated - do not use for new applications.
// Token directly in URL fragment (insecure)
https://yourapp.com/callback#access_token=xyz&token_type=bearer
Client Credentials Flow
For server-to-server communication, no user involved:
async function getClientCredentialsToken(
tokenEndpoint: string,
clientId: string,
clientSecret: string
): Promise<{ access_token: string }> {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
}).toString(),
});
return response.json();
}
Device Authorization Flow
For devices without browsers (smart TVs, CLI tools):
1. Device shows: "Go to github.com/device and enter code: ABCD-1234"
2. User opens URL on their phone/computer
3. User enters code and approves
4. Device polls for token and eventually receives it
PKCE (Proof Key for Code Exchange)
An extension for public clients (mobile apps, SPAs) that cannot keep secrets:
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// Usage in authorization request
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// Later, in token exchange, include:
// code_verifier: codeVerifier
Security Considerations
1. Always Use HTTPS
OAuth tokens grant access to user accounts. Never send them over HTTP.
2. Validate the State Parameter
The state parameter prevents CSRF attacks:
// When initiating OAuth:
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
// In the callback:
const receivedState = new URL(window.location.href).searchParams.get('state');
const savedState = sessionStorage.getItem('oauth_state');
if (receivedState !== savedState) {
throw new Error('Invalid state - possible attack!');
}
3. Keep Client Secrets Secret
Never expose client secrets in client-side code:
// BAD: Secret in frontend code
const CLIENT_SECRET = 'super-secret-123'; // Anyone can see this!
// GOOD: Token exchange happens on your server
// Frontend sends code to your backend
// Backend exchanges code using secret
4. Use Short-Lived Access Tokens
Access tokens should expire quickly. Use refresh tokens for long-term access.
5. Store Tokens Securely
// Browser: HttpOnly cookies are best (set by server)
// Fallback: sessionStorage > localStorage
// Node.js: Environment variables or secret managers
const token = process.env.ACCESS_TOKEN;
OAuth in Action: Common Providers
GitHub OAuth URLs
const GITHUB_CONFIG = {
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
userInfoEndpoint: 'https://api.github.com/user',
scopes: ['read:user', 'user:email'],
};
Google OAuth URLs
const GOOGLE_CONFIG = {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
userInfoEndpoint: 'https://www.googleapis.com/oauth2/v2/userinfo',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
],
};
Twitter/X OAuth 2.0 URLs
const TWITTER_CONFIG = {
authorizationEndpoint: 'https://twitter.com/i/oauth2/authorize',
tokenEndpoint: 'https://api.twitter.com/2/oauth2/token',
userInfoEndpoint: 'https://api.twitter.com/2/users/me',
scopes: ['tweet.read', 'users.read'],
};
Conceptual Example: Full OAuth Flow
Here is how all the pieces fit together:
// Configuration
const config = {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
authEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
redirectUri: 'http://localhost:3000/auth/callback',
scopes: ['read:user', 'user:email'],
};
// Step 1: Generate authorization URL
function getAuthorizationUrl(): { url: string; state: string } {
const state = crypto.randomUUID();
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scopes.join(' '),
state: state,
});
return {
url: `${config.authEndpoint}?${params.toString()}`,
state: state,
};
}
// Step 2: Handle callback and exchange code
async function handleCallback(
code: string,
receivedState: string,
expectedState: string
): Promise<{ accessToken: string; user: unknown }> {
// Verify state
if (receivedState !== expectedState) {
throw new Error('Invalid state parameter');
}
// Exchange code for token
const tokenResponse = await fetch(config.tokenEndpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
code: code,
redirect_uri: config.redirectUri,
}).toString(),
});
const tokens = await tokenResponse.json();
if (tokens.error) {
throw new Error(`OAuth error: ${tokens.error_description}`);
}
// Fetch user info
const userResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: 'application/vnd.github.v3+json',
},
});
const user = await userResponse.json();
return {
accessToken: tokens.access_token,
user: user,
};
}
// Usage flow:
// 1. User clicks "Login with GitHub"
// 2. Generate auth URL and save state
// const { url, state } = getAuthorizationUrl();
// saveState(state); // Store in session
// redirect(url); // Send user to GitHub
//
// 3. User approves on GitHub
// 4. GitHub redirects to /auth/callback?code=xxx&state=yyy
// 5. Handle callback
// const { accessToken, user } = await handleCallback(code, state, savedState);
// createSession(user, accessToken);
Exercises
Exercise 1: Build Authorization URL
Create a function that builds a complete OAuth authorization URL:
interface OAuthConfig {
authEndpoint: string;
clientId: string;
redirectUri: string;
scopes: string[];
}
function buildAuthUrl(config: OAuthConfig): { url: string; state: string } {}
Solution
interface OAuthConfig {
authEndpoint: string;
clientId: string;
redirectUri: string;
scopes: string[];
}
function generateState(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
function buildAuthUrl(config: OAuthConfig): { url: string; state: string } {
const state = generateState();
const url = new URL(config.authEndpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', config.clientId);
url.searchParams.set('redirect_uri', config.redirectUri);
url.searchParams.set('scope', config.scopes.join(' '));
url.searchParams.set('state', state);
return {
url: url.toString(),
state: state,
};
}
// Test
const { url, state } = buildAuthUrl({
authEndpoint: 'https://github.com/login/oauth/authorize',
clientId: 'my-client-id',
redirectUri: 'http://localhost:3000/callback',
scopes: ['read:user', 'user:email'],
});
console.log('Auth URL:', url);
console.log('State (save this):', state);
Exercise 2: Parse Callback URL
Create a function that extracts and validates OAuth callback parameters:
interface CallbackParams {
code: string;
state: string;
}
function parseOAuthCallback(callbackUrl: string): CallbackParams {
// Should throw descriptive errors for missing/invalid parameters
}
Solution
interface CallbackParams {
code: string;
state: string;
}
interface CallbackError {
error: string;
errorDescription?: string;
}
function parseOAuthCallback(callbackUrl: string): CallbackParams {
const url = new URL(callbackUrl);
// Check for error response
const error = url.searchParams.get('error');
if (error) {
const errorDescription = url.searchParams.get('error_description');
throw new Error(`OAuth error: ${error}${errorDescription ? ` - ${errorDescription}` : ''}`);
}
// Extract code
const code = url.searchParams.get('code');
if (!code) {
throw new Error(
'Missing authorization code in callback URL. ' + 'The user may have denied the request.'
);
}
// Extract state
const state = url.searchParams.get('state');
if (!state) {
throw new Error(
'Missing state parameter in callback URL. ' + 'This could indicate a CSRF attack.'
);
}
return { code, state };
}
// Test with valid callback
try {
const params = parseOAuthCallback('http://localhost:3000/callback?code=abc123&state=xyz789');
console.log('Code:', params.code);
console.log('State:', params.state);
} catch (error) {
console.error(error);
}
// Test with error callback
try {
parseOAuthCallback(
'http://localhost:3000/callback?error=access_denied&error_description=The%20user%20denied%20the%20request'
);
} catch (error) {
console.error('Expected error:', error);
}
Exercise 3: OAuth Client Class
Create a reusable OAuth client class:
class OAuthClient {
constructor(config: OAuthConfig) {}
// Generate authorization URL
getAuthorizationUrl(): { url: string; state: string } {}
// Validate callback and exchange code for token
async handleCallback(callbackUrl: string, expectedState: string): Promise<TokenResponse> {}
}
Solution
interface OAuthClientConfig {
clientId: string;
clientSecret: string;
authEndpoint: string;
tokenEndpoint: string;
redirectUri: string;
scopes: string[];
}
interface TokenResponse {
accessToken: string;
refreshToken?: string;
expiresIn?: number;
tokenType: string;
scope: string;
}
class OAuthClient {
private config: OAuthClientConfig;
constructor(config: OAuthClientConfig) {
this.config = config;
}
private generateState(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
getAuthorizationUrl(): { url: string; state: string } {
const state = this.generateState();
const url = new URL(this.config.authEndpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', this.config.clientId);
url.searchParams.set('redirect_uri', this.config.redirectUri);
url.searchParams.set('scope', this.config.scopes.join(' '));
url.searchParams.set('state', state);
return { url: url.toString(), state };
}
async handleCallback(callbackUrl: string, expectedState: string): Promise<TokenResponse> {
const url = new URL(callbackUrl);
// Check for errors
const error = url.searchParams.get('error');
if (error) {
const description = url.searchParams.get('error_description') || 'Unknown error';
throw new Error(`OAuth error: ${error} - ${description}`);
}
// Validate state
const state = url.searchParams.get('state');
if (state !== expectedState) {
throw new Error('State mismatch - possible CSRF attack');
}
// Get code
const code = url.searchParams.get('code');
if (!code) {
throw new Error('Missing authorization code');
}
// Exchange code for tokens
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uri: this.config.redirectUri,
}).toString(),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Token exchange failed: ${errorBody}`);
}
const tokens = await response.json();
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
tokenType: tokens.token_type,
scope: tokens.scope,
};
}
async refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}).toString(),
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const tokens = await response.json();
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
tokenType: tokens.token_type,
scope: tokens.scope,
};
}
}
// Usage
const github = new OAuthClient({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
authEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
redirectUri: 'http://localhost:3000/auth/callback',
scopes: ['read:user', 'user:email'],
});
// Step 1: Get auth URL
const { url, state } = github.getAuthorizationUrl();
console.log('Redirect user to:', url);
console.log('Save this state:', state);
// Step 2: After callback (in callback handler)
// const tokens = await github.handleCallback(callbackUrl, savedState);
Key Takeaways
- OAuth allows users to grant limited access without sharing passwords
- The Authorization Code flow is the most secure for web applications
- Scopes define what permissions are being requested
- The state parameter prevents CSRF attacks - always validate it
- Access tokens are short-lived; refresh tokens maintain long-term access
- Client secrets must never be exposed in client-side code
- Always use HTTPS for OAuth flows
- Authentication (who you are) is different from Authorization (what you can do)
Resources
| Resource | Type | Level |
|---|---|---|
| OAuth 2.0 Simplified | Tutorial | Beginner |
| RFC 6749: OAuth 2.0 | Specification | Advanced |
| GitHub OAuth Docs | Documentation | Beginner |
| Google OAuth Docs | Documentation | Beginner |
Next Lesson
Now that you understand authentication concepts, let us put them into practice by building a complete integration with the GitHub API.