http clients and apis

JWT And Bearer Token Orientation

A bearer token is a credential sent with a request, usually in the Authorization header. Whoever "bears" the token can use it, so it must be protected like a password.

A JWT, or JSON Web Token, is one possible token format. It contains three base64url-encoded parts separated by dots: header, payload, and signature. JWTs are common in OAuth and API authentication, but decoding a JWT is not the same as trusting it.

Bearer tokens

Clients usually send bearer tokens like this:

Authorization: Bearer access_token_here

In PHP, parse the header carefully.

PHP example
<?php

declare(strict_types=1);

function bearerTokenFromHeader(?string $authorization): ?string
{
    if ($authorization === null) {
        return null;
    }

    if (!preg_match('/^Bearer\s+(.+)$/i', trim($authorization), $matches)) {
        return null;
    }

    return $matches[1];
}

var_dump(bearerTokenFromHeader('Bearer token_123'));
var_dump(bearerTokenFromHeader('Basic abc123'));

// Prints:
// string(9) "token_123"
// NULL

Never log bearer tokens. If logs need to mention a token, log a short fingerprint such as the last few characters or a hash.

JWT shape

A JWT has three parts:

  • Header: metadata such as algorithm and key ID.
  • Payload: claims such as subject, issuer, audience, scopes, and expiry.
  • Signature: proof that the token was issued by a trusted party and has not been changed.
PHP example
<?php

declare(strict_types=1);

function jwtHasThreeParts(string $token): bool
{
    return count(explode('.', $token)) === 3;
}

var_dump(jwtHasThreeParts('header.payload.signature'));
var_dump(jwtHasThreeParts('not-a-jwt'));

// Prints:
// bool(true)
// bool(false)

This only checks the shape. It does not verify the token.

Decoding is not validation

JWT headers and payloads are base64url encoded, not encrypted. Anyone with the token can decode them.

PHP example
<?php

declare(strict_types=1);

function base64UrlDecode(string $value): string
{
    $padding = strlen($value) % 4;

    if ($padding > 0) {
        $value .= str_repeat('=', 4 - $padding);
    }

    return base64_decode(strtr($value, '-_', '+/'), true) ?: '';
}

function decodeJwtPayloadWithoutTrusting(string $token): ?array
{
    $parts = explode('.', $token);

    if (count($parts) !== 3) {
        return null;
    }

    $json = base64UrlDecode($parts[1]);

    return json_decode($json, true, flags: JSON_THROW_ON_ERROR);
}

$payload = base64UrlDecode('eyJzdWIiOiJ1c2VyXzEyMyIsInNjb3BlIjoicmVhZDpwcm9maWxlIn0');
echo $payload . PHP_EOL;

// Prints:
// {"sub":"user_123","scope":"read:profile"}

This is useful for debugging, but the application must not authorise a request from this decoded data alone.

What real validation checks

Production JWT validation should be done with a maintained library, not hand-written string code. A validator should check:

  • The signature matches a trusted key.
  • The algorithm is allowed and not chosen blindly from the token.
  • The issuer is the expected authorization server.
  • The audience matches your API.
  • The token has not expired.
  • The token is not being used before its valid time.
  • Required scopes or permissions are present.

The code you review might look conceptually like this:

PHP example
<?php

declare(strict_types=1);

function tokenClaimsAreUsable(array $claims, string $expectedIssuer, string $expectedAudience, int $now): bool
{
    if (($claims['iss'] ?? null) !== $expectedIssuer) {
        return false;
    }

    if (($claims['aud'] ?? null) !== $expectedAudience) {
        return false;
    }

    if (($claims['exp'] ?? 0) <= $now) {
        return false;
    }

    return true;
}

$claims = [
    'iss' => 'https://auth.example.test',
    'aud' => 'api.example.test',
    'exp' => 1779973500,
];

var_dump(tokenClaimsAreUsable($claims, 'https://auth.example.test', 'api.example.test', 1779969900));

// Prints:
// bool(true)

This example checks claims only. Signature verification still belongs to a JWT library.

Scopes and permissions

Tokens often include scopes such as read:orders or write:orders. Scopes say what the client application may do. They are not always the same as user roles.

PHP example
<?php

declare(strict_types=1);

function tokenHasScope(array $claims, string $requiredScope): bool
{
    $scope = (string) ($claims['scope'] ?? '');
    $scopes = preg_split('/\s+/', trim($scope)) ?: [];

    return in_array($requiredScope, $scopes, true);
}

var_dump(tokenHasScope(['scope' => 'read:orders write:orders'], 'write:orders'));
var_dump(tokenHasScope(['scope' => 'read:orders'], 'write:orders'));

// Prints:
// bool(true)
// bool(false)

An API usually needs both authentication and authorisation: first identify the token, then check whether it has permission for the action.

What to check

Before moving on, make sure you can:

  • Extract a bearer token from an authorization header.
  • Explain why bearer tokens must not be logged.
  • Recognise the three-part shape of a JWT.
  • Explain why decoding is not validation.
  • List the main checks a JWT validator must perform.
  • Check scopes or permissions after the token is trusted.

Practice

Practice: Inspect A Bearer Token

Write a PHP function that models the safe checks around a bearer token after a trusted JWT library has already verified the signature.

Requirements

  • Extract the bearer token from an Authorization header.
  • Reject missing or malformed bearer headers.
  • Accept verified claims as an array.
  • Check issuer, audience, expiry, and a required scope.
  • Return a status code and message for each failure.
  • Include examples for valid, missing header, expired token, and missing scope.
Show solution

This example assumes a JWT library has already verified the token signature. The function handles the surrounding API checks that still need to happen.

PHP example
<?php

declare(strict_types=1);

function extractBearerToken(?string $authorization): ?string
{
    if ($authorization === null) {
        return null;
    }

    if (!preg_match('/^Bearer\s+(.+)$/i', trim($authorization), $matches)) {
        return null;
    }

    return $matches[1];
}

function hasScope(array $claims, string $requiredScope): bool
{
    $scopes = preg_split('/\s+/', trim((string) ($claims['scope'] ?? ''))) ?: [];

    return in_array($requiredScope, $scopes, true);
}

function authoriseApiRequest(?string $authorization, array $verifiedClaims, int $now): array
{
    if (extractBearerToken($authorization) === null) {
        return ['status' => 401, 'message' => 'Bearer token is required.'];
    }

    if (($verifiedClaims['iss'] ?? null) !== 'https://auth.example.test') {
        return ['status' => 401, 'message' => 'Token issuer is not trusted.'];
    }

    if (($verifiedClaims['aud'] ?? null) !== 'api.example.test') {
        return ['status' => 401, 'message' => 'Token audience is invalid.'];
    }

    if (($verifiedClaims['exp'] ?? 0) <= $now) {
        return ['status' => 401, 'message' => 'Token has expired.'];
    }

    if (!hasScope($verifiedClaims, 'write:orders')) {
        return ['status' => 403, 'message' => 'Token does not have the required scope.'];
    }

    return ['status' => 200, 'message' => 'Request is authorised.'];
}

$now = 1779969900;
$validClaims = [
    'iss' => 'https://auth.example.test',
    'aud' => 'api.example.test',
    'exp' => 1779973500,
    'scope' => 'read:orders write:orders',
];

$examples = [
    authoriseApiRequest('Bearer token_123', $validClaims, $now),
    authoriseApiRequest(null, $validClaims, $now),
    authoriseApiRequest('Bearer token_123', [...$validClaims, 'exp' => 100], $now),
    authoriseApiRequest('Bearer token_123', [...$validClaims, 'scope' => 'read:orders'], $now),
];

foreach ($examples as $result) {
    echo $result['status'] . ' ' . $result['message'] . PHP_EOL;
}

// Prints:
// 200 Request is authorised.
// 401 Bearer token is required.
// 401 Token has expired.
// 403 Token does not have the required scope.

The lesson boundary matters here: this code does not verify a JWT signature. That should be delegated to a proper JWT library before the claims are trusted.