http clients and apis
OAuth Client Basics
OAuth lets a user or organisation grant your application limited access to another system without giving your application their password. A PHP app might use OAuth to connect to Google, GitHub, Xero, Shopify, Slack, Microsoft, or an internal identity provider.
This lesson is about the client side: your PHP application redirects the user to an authorization server, receives an authorization code, exchanges that code for tokens, stores the tokens safely, and uses an access token when calling the API.
The authorization code flow
The common web-app flow looks like this:
- Your app creates a random
statevalue and stores it in the user's session. - Your app redirects the user to the provider's authorization URL.
- The provider asks the user to approve access.
- The provider redirects back to your callback URL with
codeandstate. - Your app verifies the returned
state. - Your app exchanges the
codefor an access token, and often a refresh token. - Your app uses the access token in API requests.
The authorization code is short-lived and single-use. It is not the access token.
Building the authorization URL
<?php
declare(strict_types=1);
function authorizationUrl(array $config, string $state): string
{
$query = http_build_query([
'response_type' => 'code',
'client_id' => $config['client_id'],
'redirect_uri' => $config['redirect_uri'],
'scope' => implode(' ', $config['scopes']),
'state' => $state,
]);
return $config['authorization_endpoint'] . '?' . $query;
}
$url = authorizationUrl([
'authorization_endpoint' => 'https://auth.example.test/oauth/authorize',
'client_id' => 'client_123',
'redirect_uri' => 'https://app.example.test/oauth/callback',
'scopes' => ['read:profile', 'read:email'],
], 'state_abc123');
echo $url . PHP_EOL;
// Prints:
// response_type=code
// client_id=client_123
// state=state_abc123
The state value protects the callback from being accepted out of context. Use a cryptographically random value in real code, store it server-side, and compare it on callback.
Callback validation
The callback may contain either an error or a code. Always handle both.
<?php
declare(strict_types=1);
function validateOAuthCallback(array $query, string $expectedState): array
{
if (isset($query['error'])) {
return ['ok' => false, 'message' => 'Provider returned error: ' . $query['error']];
}
if (($query['state'] ?? '') !== $expectedState) {
return ['ok' => false, 'message' => 'OAuth state did not match.'];
}
$code = trim((string) ($query['code'] ?? ''));
if ($code === '') {
return ['ok' => false, 'message' => 'Authorization code is missing.'];
}
return ['ok' => true, 'code' => $code];
}
print_r(validateOAuthCallback(['code' => 'code_123', 'state' => 'state_abc'], 'state_abc'));
print_r(validateOAuthCallback(['code' => 'code_123', 'state' => 'wrong'], 'state_abc'));
// Prints:
// [ok] => 1
// [code] => code_123
// [ok] =>
// [message] => OAuth state did not match.
Do not skip the state check. It is one of the most common mistakes in hand-written OAuth integrations.
Token exchange
After the callback is validated, the app exchanges the code for tokens by making a server-side HTTP request to the provider's token endpoint.
<?php
declare(strict_types=1);
function tokenExchangeRequest(array $config, string $code): array
{
return [
'method' => 'POST',
'url' => $config['token_endpoint'],
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/x-www-form-urlencoded',
],
'form_params' => [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $config['redirect_uri'],
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
],
];
}
print_r(tokenExchangeRequest([
'token_endpoint' => 'https://auth.example.test/oauth/token',
'redirect_uri' => 'https://app.example.test/oauth/callback',
'client_id' => 'client_123',
'client_secret' => 'secret_456',
], 'code_123'));
// Prints:
// [grant_type] => authorization_code
// [code] => code_123
Some providers use HTTP Basic authentication for the client credentials instead of putting client_secret in the form body. Follow the provider's documentation.
Access tokens and refresh tokens
An access token is sent to the API, usually as a bearer token:
<?php
declare(strict_types=1);
function apiRequestWithBearerToken(string $url, string $accessToken): array
{
return [
'method' => 'GET',
'url' => $url,
'headers' => [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $accessToken,
],
];
}
print_r(apiRequestWithBearerToken('https://api.example.test/profile', 'access_token_123'));
// Prints:
// [Authorization] => Bearer access_token_123
Access tokens often expire quickly. Refresh tokens are used to obtain new access tokens without sending the user through the approval screen again. Store refresh tokens carefully because they are long-lived credentials.
PKCE
Modern OAuth clients often use PKCE, especially public clients and many web integrations. PKCE adds a code_verifier and code_challenge so an intercepted authorization code is not enough to get tokens.
<?php
declare(strict_types=1);
function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
$codeVerifier = 'fixed-example-verifier';
$codeChallenge = base64UrlEncode(hash('sha256', $codeVerifier, true));
echo $codeChallenge . PHP_EOL;
// Prints:
// 3sBTL_HGtzUBX5ReyLDCNS36OZRpy4G4BHKDBUnXZFE
In real code, the verifier should be random and stored until the callback. The token exchange then sends the original verifier.
What to check
Before moving on, make sure you can:
- Build an authorization URL with scopes and state.
- Validate callback errors, state, and code.
- Describe the token exchange request.
- Use a bearer access token in an API call.
- Explain why refresh tokens and client secrets must be protected.
- Recognise where PKCE fits into the flow.
Practice
Practice: OAuth Callback Handling
Write a small PHP example that models the OAuth callback step.
Requirements
- Accept callback query parameters and the expected state value.
- Handle provider errors.
- Reject callbacks where
statedoes not match. - Reject callbacks with no authorization code.
- Return a token-exchange request shape for a valid callback.
- Include examples for valid, state mismatch, provider error, and missing code cases.
Show solution
This solution validates the callback before building the server-side token exchange request.
<?php
declare(strict_types=1);
function handleOAuthCallback(array $query, string $expectedState, array $config): array
{
if (isset($query['error'])) {
return ['ok' => false, 'message' => 'Provider error: ' . (string) $query['error']];
}
if (($query['state'] ?? '') !== $expectedState) {
return ['ok' => false, 'message' => 'OAuth state mismatch.'];
}
$code = trim((string) ($query['code'] ?? ''));
if ($code === '') {
return ['ok' => false, 'message' => 'Authorization code is missing.'];
}
return [
'ok' => true,
'token_request' => [
'method' => 'POST',
'url' => $config['token_endpoint'],
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/x-www-form-urlencoded',
],
'form_params' => [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $config['redirect_uri'],
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
],
],
];
}
$config = [
'token_endpoint' => 'https://auth.example.test/oauth/token',
'redirect_uri' => 'https://app.example.test/oauth/callback',
'client_id' => 'client_123',
'client_secret' => 'secret_456',
];
$examples = [
handleOAuthCallback(['code' => 'code_123', 'state' => 'state_abc'], 'state_abc', $config),
handleOAuthCallback(['code' => 'code_123', 'state' => 'wrong'], 'state_abc', $config),
handleOAuthCallback(['error' => 'access_denied', 'state' => 'state_abc'], 'state_abc', $config),
handleOAuthCallback(['state' => 'state_abc'], 'state_abc', $config),
];
foreach ($examples as $result) {
echo ($result['ok'] ? 'ok' : 'error') . ': ';
echo $result['ok']
? $result['token_request']['form_params']['code']
: $result['message'];
echo PHP_EOL;
}
// Prints:
// ok: code_123
// error: OAuth state mismatch.
// error: Provider error: access_denied
// error: Authorization code is missing.
The callback handler does not fetch tokens directly in this example. It proves the validation first, then returns the request shape that an HTTP client would send.