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
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
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
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
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
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.
Bearer Means Possession Is Enough
A bearer token authorizes whoever presents it. The server does not normally require proof that the presenter is the client to which the token was originally issued. Transport, storage, logging, and lifetime controls therefore matter as much as token parsing.
Send access tokens in the HTTP Authorization header using the Bearer scheme and require HTTPS. Avoid query-string tokens because URLs are copied into browser history, access logs, analytics systems, screenshots, and referrer headers. Configure proxies and application logging to redact credentials.
A missing credential and an invalid credential generally produce 401 Unauthorized, often with a WWW-Authenticate challenge. A valid identity that lacks permission normally produces 403 Forbidden. Resource-hiding policies may intentionally return 404, but that choice should be consistent and documented.
Decoding Is Only Inspection
A JWT can be split and base64url-decoded without a key. That is useful for debugging shape, but it establishes no trust. Every claim remains attacker-controlled until a maintained library verifies the signature under an allowed algorithm and trusted key.
Signed JWTs are readable. Treat the payload like data visible to the token holder and to any system that accidentally receives the credential. Keep it small because it is sent on every request, and avoid volatile authorization details that cannot remain valid for the token lifetime.
Validation Builds An Authentication Principal
The validation layer should pin accepted algorithms, choose keys from trusted issuer configuration, verify the signature, require the expected issuer and audience, and enforce expiration and not-before claims. Validate claim types rather than relying on PHP coercion.
After validation, translate external claims into an application-owned principal. The rest of the application should not repeatedly parse arrays named by an identity provider. A principal can expose a stable subject, tenant, and granted scopes while preserving the raw token only where diagnostics require it.
Do not let a client choose which issuer configuration applies through an arbitrary URL or tenant string. Map a bounded issuer identifier through trusted server configuration. Multi-issuer systems need explicit rules for claim mapping and key refresh.
Scopes Describe Permission Categories
Scopes are usually coarse capabilities such as reading orders or sending messages. They do not replace object-level authorization. A token with orders:read still needs an ownership or tenant check for order 42.
Normalize scope representation according to the issuer contract. Some tokens use a space-delimited scope string; others use an array claim under another name. Reject malformed types and avoid substring checks: orders:read-all must not accidentally satisfy orders:read.
Roles, scopes, entitlements, and resource state answer different questions. Keep the policy near the use case and test valid tokens that are denied because the resource belongs to another customer or is in a terminal state.
Service-To-Service Tokens
Machine clients should obtain tokens through an appropriate OAuth flow or workload identity rather than sharing a human token or static credential. Give each workload a distinct identity and the smallest audience and scope set it needs.
Cache tokens until shortly before expiration to avoid contacting the issuer on every request. Coordinate refresh so many workers do not request a new token simultaneously. Never continue using an expired token because the issuer is unavailable; define whether the calling operation should fail, queue, or degrade.
Sender-constrained token mechanisms can reduce bearer-token replay in higher-risk systems, but they add certificate or proof-key lifecycle. Adopt them through the authorization platform rather than inventing a custom token binding.
Browser Storage Decisions
JavaScript-accessible storage exposes tokens to successful XSS. HttpOnly cookies can keep credentials out of JavaScript, but cookie-authenticated APIs then need CSRF protections and careful SameSite configuration. A backend-for-frontend can store OAuth tokens server-side and issue a narrower browser session.
There is no universal storage rule independent of architecture. Document which component holds the access and refresh tokens, what an injected script can reach, how logout and revocation work, and how multiple tabs refresh credentials without races.
Error And Retry Behavior
Do not retry an API request indefinitely after 401. A client may perform one controlled refresh when the access token expired and the operation is safe under its retry policy. If refresh fails, clear the local session and require authentication rather than looping.
A 403 normally indicates policy, not an expired credential, so refreshing the same identity is unlikely to help. Preserve response correlation IDs and a safe error code for support without returning signature details or claim contents.
On the server, distinguish malformed credentials, failed verification, unavailable key infrastructure, and denied authorization in internal metrics. Public responses remain deliberately limited to avoid giving attackers a verification oracle.
Rotation And Revocation
Asymmetric issuers publish public keys under identifiers. Cache the key set, refresh it in a bounded way for unknown identifiers, and allow old and new keys to overlap during rotation. Test issuer downtime with cached keys.
Short-lived access tokens reduce the revocation window. Immediate disablement may require introspection, a session or token-version lookup, or a denylist. Refresh-token rotation and reuse detection help contain stolen long-lived credentials.
Integration Testing
Test the HTTP path with missing headers, wrong schemes, duplicated authorization headers, malformed JWT segments, bad signatures, wrong issuer and audience, clock boundaries, and unknown keys. Verify exact 401, 403, or policy-driven 404 behavior and the WWW-Authenticate header where required.
Add authorization tests using valid tokens from two users and two tenants. Inspect reverse-proxy and application logs to ensure credentials are redacted. Exercise one refresh race and an issuer-key rotation in a controlled environment.
Token Size And Cache Behavior
Large JWTs increase every authenticated request and can exceed proxy or server header limits. Keep claims necessary for the receiving API and avoid embedding full permission lists or profiles that change frequently. Measure the encoded token, not only the JSON payload, because base64url and signatures add overhead.
Authenticated responses are not automatically uncacheable, but shared caching requires careful Cache-Control, Vary, and authorization design. Do not let a response personalized by token claims enter a shared cache under a URL-only key. Private browser caches also need explicit policy for sensitive data.
At gateways, define maximum header size and behavior for duplicate Authorization headers. Reject ambiguity rather than letting different layers choose different values. Include these limits in integration tests because an in-process middleware test bypasses the proxy that may reject the request first.
After this lesson, you should be able to explain bearer-token risk, distinguish decoding from verification, construct an application principal from validated claims, apply scopes alongside resource authorization, choose browser and service token lifecycles, and implement predictable HTTP errors and refresh behavior.
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
Authorizationheader. - 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
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.