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:
- Implement OAuth 2.0 Authorization Code flow with PKCE
- Create a JWT authentication system with custom claims
- Build a refresh token system with token rotation
- Implement reuse detection to prevent token theft
- Add scope-based authorization to your API endpoints
- 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