OAuth 2.0 enables secure delegated authorization. Implementing it correctly is crucial for security. Here's how to do it right.
OAuth Flows#
Authorization Code (with PKCE):
- Best for web and mobile apps
- Most secure
- Server exchanges code for tokens
Client Credentials:
- Machine-to-machine
- No user involvement
- Service accounts
Implicit (Deprecated):
- Don't use for new apps
- Tokens exposed in URL
- Replaced by Auth Code + PKCE
Resource Owner Password:
- Legacy systems only
- User credentials sent to client
- Avoid if possible
Authorization Code Flow with PKCE#
1import crypto from 'crypto';
2
3// Client-side: Generate PKCE verifier and challenge
4function generatePKCE(): { verifier: string; challenge: string } {
5 const verifier = crypto.randomBytes(32).toString('base64url');
6 const challenge = crypto
7 .createHash('sha256')
8 .update(verifier)
9 .digest('base64url');
10
11 return { verifier, challenge };
12}
13
14// Step 1: Redirect to authorization endpoint
15function getAuthorizationUrl(state: string, pkce: { challenge: string }): string {
16 const params = new URLSearchParams({
17 client_id: process.env.OAUTH_CLIENT_ID!,
18 redirect_uri: process.env.OAUTH_REDIRECT_URI!,
19 response_type: 'code',
20 scope: 'openid profile email',
21 state,
22 code_challenge: pkce.challenge,
23 code_challenge_method: 'S256',
24 });
25
26 return `https://auth.example.com/authorize?${params}`;
27}
28
29// Step 2: Handle callback and exchange code for tokens
30async function handleCallback(
31 code: string,
32 verifier: string
33): Promise<TokenResponse> {
34 const response = await fetch('https://auth.example.com/token', {
35 method: 'POST',
36 headers: {
37 'Content-Type': 'application/x-www-form-urlencoded',
38 },
39 body: new URLSearchParams({
40 grant_type: 'authorization_code',
41 client_id: process.env.OAUTH_CLIENT_ID!,
42 client_secret: process.env.OAUTH_CLIENT_SECRET!,
43 redirect_uri: process.env.OAUTH_REDIRECT_URI!,
44 code,
45 code_verifier: verifier,
46 }),
47 });
48
49 if (!response.ok) {
50 const error = await response.json();
51 throw new OAuthError(error.error, error.error_description);
52 }
53
54 return response.json();
55}
56
57interface TokenResponse {
58 access_token: string;
59 token_type: string;
60 expires_in: number;
61 refresh_token?: string;
62 id_token?: string;
63 scope: string;
64}OAuth Server Implementation#
1import express from 'express';
2import jwt from 'jsonwebtoken';
3
4// Authorization endpoint
5app.get('/authorize', async (req, res) => {
6 const {
7 client_id,
8 redirect_uri,
9 response_type,
10 scope,
11 state,
12 code_challenge,
13 code_challenge_method,
14 } = req.query;
15
16 // Validate client
17 const client = await db.oauthClient.findUnique({
18 where: { clientId: client_id as string },
19 });
20
21 if (!client) {
22 return res.status(400).json({ error: 'invalid_client' });
23 }
24
25 // Validate redirect URI
26 if (!client.redirectUris.includes(redirect_uri as string)) {
27 return res.status(400).json({ error: 'invalid_redirect_uri' });
28 }
29
30 // Store authorization request
31 const authRequest = await db.authRequest.create({
32 data: {
33 clientId: client_id as string,
34 redirectUri: redirect_uri as string,
35 scope: scope as string,
36 state: state as string,
37 codeChallenge: code_challenge as string,
38 codeChallengeMethod: code_challenge_method as string,
39 expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
40 },
41 });
42
43 // Show consent screen
44 res.render('consent', {
45 client,
46 scopes: (scope as string).split(' '),
47 authRequestId: authRequest.id,
48 });
49});
50
51// User approves consent
52app.post('/authorize/consent', authenticate, async (req, res) => {
53 const { authRequestId, approved } = req.body;
54
55 const authRequest = await db.authRequest.findUnique({
56 where: { id: authRequestId },
57 });
58
59 if (!authRequest || authRequest.expiresAt < new Date()) {
60 return res.status(400).json({ error: 'invalid_request' });
61 }
62
63 if (!approved) {
64 const redirectUrl = new URL(authRequest.redirectUri);
65 redirectUrl.searchParams.set('error', 'access_denied');
66 redirectUrl.searchParams.set('state', authRequest.state);
67 return res.redirect(redirectUrl.toString());
68 }
69
70 // Generate authorization code
71 const code = crypto.randomBytes(32).toString('hex');
72
73 await db.authCode.create({
74 data: {
75 code,
76 clientId: authRequest.clientId,
77 userId: req.user.id,
78 redirectUri: authRequest.redirectUri,
79 scope: authRequest.scope,
80 codeChallenge: authRequest.codeChallenge,
81 codeChallengeMethod: authRequest.codeChallengeMethod,
82 expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
83 },
84 });
85
86 // Redirect with code
87 const redirectUrl = new URL(authRequest.redirectUri);
88 redirectUrl.searchParams.set('code', code);
89 redirectUrl.searchParams.set('state', authRequest.state);
90
91 res.redirect(redirectUrl.toString());
92});
93
94// Token endpoint
95app.post('/token', async (req, res) => {
96 const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token } =
97 req.body;
98
99 // Validate client credentials
100 const client = await db.oauthClient.findUnique({
101 where: { clientId: client_id },
102 });
103
104 if (!client || client.clientSecret !== client_secret) {
105 return res.status(401).json({ error: 'invalid_client' });
106 }
107
108 if (grant_type === 'authorization_code') {
109 return handleAuthorizationCodeGrant(req, res, client, code, redirect_uri, code_verifier);
110 }
111
112 if (grant_type === 'refresh_token') {
113 return handleRefreshTokenGrant(req, res, client, refresh_token);
114 }
115
116 res.status(400).json({ error: 'unsupported_grant_type' });
117});
118
119async function handleAuthorizationCodeGrant(
120 req: Request,
121 res: Response,
122 client: OAuthClient,
123 code: string,
124 redirectUri: string,
125 codeVerifier: string
126): Promise<void> {
127 const authCode = await db.authCode.findUnique({ where: { code } });
128
129 if (!authCode || authCode.expiresAt < new Date()) {
130 res.status(400).json({ error: 'invalid_grant' });
131 return;
132 }
133
134 if (authCode.clientId !== client.clientId) {
135 res.status(400).json({ error: 'invalid_grant' });
136 return;
137 }
138
139 if (authCode.redirectUri !== redirectUri) {
140 res.status(400).json({ error: 'invalid_grant' });
141 return;
142 }
143
144 // Verify PKCE
145 const challenge = crypto
146 .createHash('sha256')
147 .update(codeVerifier)
148 .digest('base64url');
149
150 if (challenge !== authCode.codeChallenge) {
151 res.status(400).json({ error: 'invalid_grant' });
152 return;
153 }
154
155 // Delete used code (one-time use)
156 await db.authCode.delete({ where: { code } });
157
158 // Generate tokens
159 const tokens = await generateTokens(authCode.userId, client, authCode.scope);
160
161 res.json(tokens);
162}
163
164async function generateTokens(
165 userId: string,
166 client: OAuthClient,
167 scope: string
168): Promise<TokenResponse> {
169 const accessToken = jwt.sign(
170 {
171 sub: userId,
172 client_id: client.clientId,
173 scope,
174 },
175 process.env.JWT_SECRET!,
176 { expiresIn: '1h' }
177 );
178
179 const refreshToken = crypto.randomBytes(32).toString('hex');
180
181 await db.refreshToken.create({
182 data: {
183 token: refreshToken,
184 userId,
185 clientId: client.clientId,
186 scope,
187 expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
188 },
189 });
190
191 return {
192 access_token: accessToken,
193 token_type: 'Bearer',
194 expires_in: 3600,
195 refresh_token: refreshToken,
196 scope,
197 };
198}Token Validation#
1// Validate access tokens
2function validateAccessToken(token: string): TokenPayload {
3 try {
4 const payload = jwt.verify(token, process.env.JWT_SECRET!) as TokenPayload;
5
6 // Check if token is revoked
7 // ... revocation check
8
9 return payload;
10 } catch (error) {
11 if (error instanceof jwt.TokenExpiredError) {
12 throw new OAuthError('invalid_token', 'Token has expired');
13 }
14 throw new OAuthError('invalid_token', 'Invalid token');
15 }
16}
17
18// Middleware
19function requireAuth(scopes?: string[]) {
20 return async (req: Request, res: Response, next: NextFunction) => {
21 const authHeader = req.headers.authorization;
22
23 if (!authHeader?.startsWith('Bearer ')) {
24 return res.status(401).json({ error: 'invalid_token' });
25 }
26
27 const token = authHeader.slice(7);
28
29 try {
30 const payload = validateAccessToken(token);
31
32 // Check scopes
33 if (scopes) {
34 const tokenScopes = payload.scope.split(' ');
35 const hasScope = scopes.every((s) => tokenScopes.includes(s));
36 if (!hasScope) {
37 return res.status(403).json({ error: 'insufficient_scope' });
38 }
39 }
40
41 req.user = payload;
42 next();
43 } catch (error) {
44 if (error instanceof OAuthError) {
45 return res.status(401).json({ error: error.code, error_description: error.message });
46 }
47 return res.status(401).json({ error: 'invalid_token' });
48 }
49 };
50}
51
52// Usage
53app.get('/api/profile', requireAuth(['profile']), async (req, res) => {
54 const user = await db.user.findUnique({ where: { id: req.user.sub } });
55 res.json(user);
56});Refresh Token Rotation#
1async function handleRefreshTokenGrant(
2 req: Request,
3 res: Response,
4 client: OAuthClient,
5 refreshToken: string
6): Promise<void> {
7 const storedToken = await db.refreshToken.findUnique({
8 where: { token: refreshToken },
9 });
10
11 if (!storedToken || storedToken.expiresAt < new Date()) {
12 res.status(400).json({ error: 'invalid_grant' });
13 return;
14 }
15
16 if (storedToken.clientId !== client.clientId) {
17 res.status(400).json({ error: 'invalid_grant' });
18 return;
19 }
20
21 // Delete old refresh token (rotation)
22 await db.refreshToken.delete({ where: { token: refreshToken } });
23
24 // Generate new tokens
25 const tokens = await generateTokens(storedToken.userId, client, storedToken.scope);
26
27 res.json(tokens);
28}Best Practices#
Security:
✓ Always use PKCE
✓ Validate redirect URIs exactly
✓ Use short-lived access tokens
✓ Rotate refresh tokens
✓ Store tokens securely
Implementation:
✓ Use state parameter against CSRF
✓ Validate all inputs
✓ Log security events
✓ Rate limit token endpoints
Token Handling:
✓ Never expose tokens in URLs
✓ Use HttpOnly cookies when possible
✓ Clear tokens on logout
✓ Implement token revocation
Conclusion#
OAuth 2.0 requires careful implementation. Use Authorization Code with PKCE, validate everything, rotate refresh tokens, and follow security best practices. When possible, use battle-tested libraries rather than implementing from scratch.