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:

  1. Your app creates a random state value and stores it in the user's session.
  2. Your app redirects the user to the provider's authorization URL.
  3. The provider asks the user to approve access.
  4. The provider redirects back to your callback URL with code and state.
  5. Your app verifies the returned state.
  6. Your app exchanges the code for an access token, and often a refresh token.
  7. 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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 state does 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 example
<?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.