REST API Development

OAuth 2.0 & JWT Deep Dive

22 min Lesson 23 of 35

OAuth 2.0 & JWT Deep Dive

OAuth 2.0 and JSON Web Tokens (JWT) are foundational technologies for modern API authentication and authorization. Understanding their internals, flows, and best practices is crucial for building secure, scalable APIs.

Understanding OAuth 2.0

OAuth 2.0 is an authorization framework that enables applications to obtain limited access to user accounts on an HTTP service. It works by delegating user authentication to the service that hosts the user account.

Key OAuth 2.0 Terminology:
  • Resource Owner: The user who owns the data
  • Client: The application requesting access to the user's data
  • Authorization Server: Issues access tokens after authenticating the user
  • Resource Server: The API server hosting the protected resources
  • Access Token: Credential used to access protected resources
  • Refresh Token: Credential used to obtain new access tokens
  • Scope: Defines the level of access granted

OAuth 2.0 Grant Types (Flows)

1. Authorization Code Flow (Most Secure)

Best for server-side web applications where the client secret can be securely stored.

<?php // Step 1: Redirect user to authorization endpoint Route::get('/auth/authorize', function () { $query = http_build_query([ 'client_id' => config('oauth.client_id'), 'redirect_uri' => route('auth.callback'), 'response_type' => 'code', 'scope' => 'read write', 'state' => Str::random(40), // CSRF protection ]); return redirect('https://auth-server.com/oauth/authorize?' . $query); }); // Step 2: Handle callback with authorization code Route::get('/auth/callback', function (Request $request) { // Verify state to prevent CSRF if ($request->state !== session('oauth_state')) { abort(403, 'Invalid state'); } // Exchange authorization code for access token $response = Http::asForm()->post('https://auth-server.com/oauth/token', [ 'grant_type' => 'authorization_code', 'client_id' => config('oauth.client_id'), 'client_secret' => config('oauth.client_secret'), 'redirect_uri' => route('auth.callback'), 'code' => $request->code, ]); $tokens = $response->json(); // Store tokens securely auth()->user()->update([ 'access_token' => encrypt($tokens['access_token']), 'refresh_token' => encrypt($tokens['refresh_token']), 'expires_at' => now()->addSeconds($tokens['expires_in']) ]); return redirect('/dashboard'); }); // Step 3: Use access token to make API requests Route::get('/api/data', function () { $accessToken = decrypt(auth()->user()->access_token); $response = Http::withToken($accessToken) ->get('https://api.example.com/data'); return $response->json(); });

2. Client Credentials Flow

Used for machine-to-machine authentication where there's no user involved.

<?php // Request access token using client credentials $response = Http::asForm()->post('https://auth-server.com/oauth/token', [ 'grant_type' => 'client_credentials', 'client_id' => config('oauth.client_id'), 'client_secret' => config('oauth.client_secret'), 'scope' => 'api.read api.write' ]); $accessToken = $response->json('access_token'); // Use token for API requests $apiResponse = Http::withToken($accessToken) ->get('https://api.example.com/resources'); // Laravel Passport example use Laravel\Passport\Client; Route::post('/oauth/token', function (Request $request) { $client = Client::where('id', $request->client_id) ->where('secret', $request->client_secret) ->firstOrFail(); // Verify client credentials and issue token $token = $client->createToken('API Access', [ 'api.read', 'api.write' ])->accessToken; return response()->json([ 'access_token' => $token, 'token_type' => 'Bearer', 'expires_in' => 3600 ]); });

3. Password Grant (Legacy, Not Recommended)

Only use for first-party applications you trust completely.

<?php // ⚠️ Use only for trusted first-party clients $response = Http::asForm()->post('https://auth-server.com/oauth/token', [ 'grant_type' => 'password', 'client_id' => config('oauth.client_id'), 'client_secret' => config('oauth.client_secret'), 'username' => $request->email, 'password' => $request->password, 'scope' => '*' ]); $tokens = $response->json();
Password Grant Deprecation: OAuth 2.1 removes the Password Grant. Use Authorization Code with PKCE instead, even for first-party applications.

4. Authorization Code with PKCE

Enhanced security for mobile and single-page applications that cannot securely store client secrets.

<?php // Generate code verifier and challenge function generatePKCE(): array { $verifier = Str::random(128); $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); return [ 'verifier' => $verifier, 'challenge' => $challenge ]; } // Step 1: Authorization request with PKCE Route::get('/auth/login', function () { $pkce = generatePKCE(); session(['code_verifier' => $pkce['verifier']]); $query = http_build_query([ 'client_id' => config('oauth.client_id'), 'redirect_uri' => route('auth.callback'), 'response_type' => 'code', 'scope' => 'read write', 'code_challenge' => $pkce['challenge'], 'code_challenge_method' => 'S256' ]); return redirect('https://auth-server.com/oauth/authorize?' . $query); }); // Step 2: Token request with code verifier Route::get('/auth/callback', function (Request $request) { $response = Http::asForm()->post('https://auth-server.com/oauth/token', [ 'grant_type' => 'authorization_code', 'client_id' => config('oauth.client_id'), // No client_secret needed with PKCE! 'redirect_uri' => route('auth.callback'), 'code' => $request->code, 'code_verifier' => session('code_verifier') ]); return $response->json(); });

JSON Web Tokens (JWT) Explained

JWT is a compact, URL-safe means of representing claims to be transferred between two parties. A JWT consists of three parts separated by dots: Header.Payload.Signature

JWT Structure

// Example JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // Decoded: // HEADER { "alg": "HS256", // Algorithm: HMAC SHA-256 "typ": "JWT" // Token type } // PAYLOAD (Claims) { "sub": "1234567890", // Subject (user ID) "name": "John Doe", // Custom claim "iat": 1516239022, // Issued at "exp": 1516242622, // Expiration time "aud": "https://api.example.com", // Audience "iss": "https://auth.example.com" // Issuer } // SIGNATURE HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )

Creating and Verifying JWTs in Laravel

<?php use Firebase\JWT\JWT; use Firebase\JWT\Key; // Create JWT function createJWT(User $user): string { $payload = [ 'iss' => config('app.url'), // Issuer 'sub' => $user->id, // Subject (user ID) 'iat' => time(), // Issued at 'exp' => time() + (60 * 60), // Expires in 1 hour 'nbf' => time(), // Not before 'jti' => Str::uuid()->toString(), // JWT ID (unique identifier) // Custom claims 'email' => $user->email, 'role' => $user->role, 'scopes' => ['read', 'write'] ]; return JWT::encode($payload, config('jwt.secret'), 'HS256'); } // Verify and decode JWT function verifyJWT(string $token): object { try { $decoded = JWT::decode( $token, new Key(config('jwt.secret'), 'HS256') ); // Additional validation if ($decoded->exp < time()) { throw new Exception('Token expired'); } if ($decoded->iss !== config('app.url')) { throw new Exception('Invalid issuer'); } return $decoded; } catch (Exception $e) { throw new AuthenticationException('Invalid token: ' . $e->getMessage()); } } // Middleware to validate JWT class JwtAuthentication { public function handle(Request $request, Closure $next) { $token = $request->bearerToken(); if (!$token) { return response()->json([ 'errors' => [[ 'status' => '401', 'title' => 'Unauthorized', 'detail' => 'No token provided' ]] ], 401); } try { $decoded = verifyJWT($token); // Attach user to request $request->merge(['authenticated_user' => User::find($decoded->sub)]); return $next($request); } catch (AuthenticationException $e) { return response()->json([ 'errors' => [[ 'status' => '401', 'title' => 'Unauthorized', 'detail' => $e->getMessage() ]] ], 401); } } }

Refresh Tokens

Access tokens should have short lifespans. Refresh tokens allow clients to obtain new access tokens without re-authentication.

<?php // Generate token pair public function login(Request $request) { $credentials = $request->only('email', 'password'); if (!auth()->attempt($credentials)) { return response()->json([ 'errors' => [[ 'status' => '401', 'title' => 'Authentication Failed', 'detail' => 'Invalid credentials' ]] ], 401); } $user = auth()->user(); // Create access token (short-lived: 15 minutes) $accessToken = createJWT($user); // Create refresh token (long-lived: 30 days) $refreshToken = Str::random(64); $user->refreshTokens()->create([ 'token' => hash('sha256', $refreshToken), 'expires_at' => now()->addDays(30) ]); return response()->json([ 'access_token' => $accessToken, 'refresh_token' => $refreshToken, 'token_type' => 'Bearer', 'expires_in' => 900 // 15 minutes ]); } // Refresh access token public function refresh(Request $request) { $refreshToken = $request->input('refresh_token'); if (!$refreshToken) { return response()->json([ 'errors' => [[ 'status' => '400', 'title' => 'Bad Request', 'detail' => 'Refresh token required' ]] ], 400); } // Find token in database $tokenRecord = RefreshToken::where('token', hash('sha256', $refreshToken)) ->where('expires_at', '>', now()) ->where('revoked', false) ->first(); if (!$tokenRecord) { return response()->json([ 'errors' => [[ 'status' => '401', 'title' => 'Unauthorized', 'detail' => 'Invalid or expired refresh token' ]] ], 401); } $user = $tokenRecord->user; // Issue new access token $newAccessToken = createJWT($user); // Optional: Implement token rotation // Revoke old refresh token and issue new one $tokenRecord->update(['revoked' => true]); $newRefreshToken = Str::random(64); $user->refreshTokens()->create([ 'token' => hash('sha256', $newRefreshToken), 'expires_at' => now()->addDays(30) ]); return response()->json([ 'access_token' => $newAccessToken, 'refresh_token' => $newRefreshToken, 'token_type' => 'Bearer', 'expires_in' => 900 ]); }

Token Rotation and Reuse Detection

Token rotation improves security by issuing a new refresh token with each refresh request.

<?php // Refresh tokens migration Schema::create('refresh_tokens', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->onDelete('cascade'); $table->string('token', 64)->unique(); $table->string('family', 36); // Track token family for reuse detection $table->boolean('revoked')->default(false); $table->timestamp('expires_at'); $table->timestamp('used_at')->nullable(); $table->timestamps(); $table->index(['user_id', 'family']); }); // Implement refresh with rotation and reuse detection public function refresh(Request $request) { $refreshToken = $request->input('refresh_token'); $hashedToken = hash('sha256', $refreshToken); $tokenRecord = RefreshToken::where('token', $hashedToken) ->where('expires_at', '>', now()) ->first(); if (!$tokenRecord) { return response()->json(['errors' => [[ 'status' => '401', 'detail' => 'Invalid refresh token' ]]], 401); } // REUSE DETECTION: If token was already used, revoke entire family if ($tokenRecord->used_at !== null) { RefreshToken::where('family', $tokenRecord->family) ->update(['revoked' => true]); return response()->json(['errors' => [[ 'status' => '401', 'detail' => 'Token reuse detected. All tokens revoked.' ]]], 401); } $user = $tokenRecord->user; // Mark current token as used $tokenRecord->update(['used_at' => now()]); // Issue new tokens in same family $newAccessToken = createJWT($user); $newRefreshToken = Str::random(64); $user->refreshTokens()->create([ 'token' => hash('sha256', $newRefreshToken), 'family' => $tokenRecord->family, 'expires_at' => now()->addDays(30) ]); return response()->json([ 'access_token' => $newAccessToken, 'refresh_token' => $newRefreshToken, 'token_type' => 'Bearer', 'expires_in' => 900 ]); }
Token Rotation Benefits: If a refresh token is stolen, it can only be used once. When the legitimate user tries to use their copy, reuse is detected and all tokens in that family are revoked.

JWT Best Practices

Security Recommendations:
  • Keep access tokens short-lived (5-15 minutes)
  • Use RS256 (RSA) instead of HS256 for public APIs
  • Never store sensitive data in JWT payload (it's not encrypted, only signed)
  • Always validate `exp`, `iss`, `aud` claims
  • Implement token revocation for logout
  • Use refresh token rotation with reuse detection
  • Store refresh tokens securely (hashed in database)
  • Use HTTPS only
  • Implement rate limiting on token endpoints
Exercise:
  1. Implement OAuth 2.0 Authorization Code flow with PKCE
  2. Create a JWT authentication system with custom claims
  3. Build a refresh token system with token rotation
  4. Implement reuse detection to prevent token theft
  5. Add scope-based authorization to your API endpoints
  6. Create a token revocation endpoint for logout
Common JWT Mistakes:
  • Using `alg: none` - Always specify an algorithm
  • Not validating the signature - Always verify tokens
  • Storing passwords or sensitive data in JWT - Never do this
  • Using long-lived access tokens - Keep them short
  • Not implementing token revocation - Plan for logout/compromised tokens