Back to blog

Security

Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch

Full implementation of OAuth 2.0 authorization code + PKCE flow in PHP, authorization endpoint, token exchange, and refresh logic.

  • PHP
  • OAuth 2.0
  • PKCE
  • Security
  • Authentication

SEO Metadata

SEO Title Options

  1. Implementing OAuth 2.0 in PHP: Authorization Code Flow
  2. Implementing OAuth 2.0 in PHP: Authorizati: Practical 2026
  3. Security Playbook: Implementing OAuth 2.0 in PHP

Meta Description Options

  1. Learn Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch with a practical Security framework, expert mistakes, implementation steps.
  2. Full implementation of OAuth 2.0 authorization code + PKCE flow in PHP, authorization endpoint, token exchange, and refresh logic.

URL Slug

implementing-oauth-20-php-authorization-code-flow-from-scratch

Focus Keyword

Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch

Additional LSI Keywords

  • Security
  • PHP
  • OAuth 2.0
  • PKCE
  • Authentication
  • Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch
  • production checklist
  • implementation guide
  • best practices
  • architecture decisions
  • testing strategy
  • performance impact

Table of Contents

Article overview

Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.

The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.

Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.

Key Takeaways

  • Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch should be evaluated as a production decision, not only as a syntax or tooling choice.
  • The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
  • Search visibility improves when practical depth, structured answers, and expert examples live on the same page.

[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch expert guide for Security]

What Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch means

Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch means applying security knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.

This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.

Why it matters now

The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.

For security topics, the strongest content now has three layers:

  • a clear answer for fast scanning
  • a practical framework for implementation
  • expert context that explains what breaks later

That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.

Implementation framework

Use this framework before adopting the approach described in this article.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. Revisit the decision after real usage exposes edge cases.

The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.

[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch implementation framework]

Practical comparison

Decision areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes the decision reversible

This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.

Expert workflow

Expert tip: "Treat Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."

A useful workflow is simple:

  • Start with the smallest working example.
  • Add the constraints that exist in your real project.
  • Remove anything that only demonstrates cleverness.
  • Write down the failure modes.
  • Add links to related decisions so future readers can navigate the topic cluster.

That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.

Common mistakes

Mistake 1: Copying a pattern without its context

A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.

Before copying the pattern, ask what assumption made it safe in the original example.

Mistake 2: Putting business logic in the wrong layer

This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.

Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.

Mistake 3: Optimizing for novelty instead of maintainability

Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.

Use the option that makes the next production incident easier to understand.

Mistake 4: Publishing without a measurement plan

If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.

[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch common mistakes]

Image placeholders

  • [IMAGE: A concept diagram for Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch with input, decision boundary, implementation, tests, and production feedback. Alt: Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch concept diagram]
  • [IMAGE: A mobile screenshot-style checklist for Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch. Alt: Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch mobile checklist]
  • [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch comparison table]

Video placeholder

[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch.]

Internal linking opportunities

Original Technical Deep Dive

Read this before copying code

OAuth is security infrastructure. Building it from scratch is useful for learning and for internal protocols, but a public authorization server should usually use a maintained OAuth or OpenID Connect server library.

This article implements the core authorization code flow with PKCE in plain PHP so the moving parts are visible:

  • Client registration.
  • Exact redirect URI validation.
  • Authorization endpoint.
  • Short-lived authorization codes.
  • PKCE S256 verification.
  • Token endpoint.
  • Bearer access tokens.
  • Refresh token rotation.
  • Protected resource validation.

This is OAuth authorization, not user authentication by itself. If you want login and identity claims, use OpenID Connect on top of OAuth 2.0.

The modern baseline is stricter than the original 2012 OAuth 2.0 examples:

  • Use authorization code flow with PKCE.
  • Use S256, not plain.
  • Do not use the implicit grant.
  • Do not use the password grant for normal applications.
  • Validate redirect URIs by exact string match.
  • Never redirect to an unregistered redirect URI.
  • Make authorization codes short-lived and single-use.
  • Store only hashes of codes and tokens.
  • Rotate refresh tokens.
  • Require HTTPS outside local development.

The flow

The authorization code + PKCE flow has two front-channel steps and one back-channel step.

Client creates code_verifier and code_challenge
Client redirects browser to /oauth/authorize
Authorization server authenticates user and asks for consent
Authorization server redirects back with ?code=...&state=...
Client posts code + code_verifier to /oauth/token
Authorization server validates code, redirect_uri, client_id, and PKCE
Authorization server returns access_token and optional refresh_token

PKCE binds the authorization request to the token request. A stolen authorization code is not enough; the attacker also needs the one-time code_verifier.

Database tables

Use proper migrations in a real project. The shape matters more than the syntax:

CREATE TABLE oauth_clients (
    id VARCHAR(80) PRIMARY KEY,
    name VARCHAR(120) NOT NULL,
    secret_hash VARCHAR(255) NULL,
    redirect_uris JSON NOT NULL,
    allowed_scopes VARCHAR(500) NOT NULL,
    is_confidential TINYINT(1) NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL
);

CREATE TABLE oauth_authorization_codes (
    code_hash CHAR(64) PRIMARY KEY,
    client_id VARCHAR(80) NOT NULL,
    user_id BIGINT NOT NULL,
    redirect_uri VARCHAR(500) NOT NULL,
    scope VARCHAR(500) NOT NULL,
    code_challenge VARCHAR(128) NOT NULL,
    code_challenge_method VARCHAR(10) NOT NULL,
    expires_at DATETIME NOT NULL,
    consumed_at DATETIME NULL,
    created_at DATETIME NOT NULL
);

CREATE TABLE oauth_access_tokens (
    token_hash CHAR(64) PRIMARY KEY,
    client_id VARCHAR(80) NOT NULL,
    user_id BIGINT NOT NULL,
    scope VARCHAR(500) NOT NULL,
    expires_at DATETIME NOT NULL,
    revoked_at DATETIME NULL,
    created_at DATETIME NOT NULL
);

CREATE TABLE oauth_refresh_tokens (
    token_hash CHAR(64) PRIMARY KEY,
    family_id CHAR(32) NOT NULL,
    client_id VARCHAR(80) NOT NULL,
    user_id BIGINT NOT NULL,
    scope VARCHAR(500) NOT NULL,
    expires_at DATETIME NOT NULL,
    revoked_at DATETIME NULL,
    replaced_by_hash CHAR(64) NULL,
    created_at DATETIME NOT NULL
);

Important storage choices:

  • Store token hashes, not raw token values.
  • Store the exact redirect_uri used with the authorization code.
  • Store the PKCE challenge and method with the code.
  • Keep refresh token families so reuse detection can revoke the active chain.

Small helpers

Use base64url for PKCE values and random tokens:

<?php

declare(strict_types=1);

function base64UrlEncode(string $bytes): string
{
    return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}

function randomToken(int $bytes = 32): string
{
    return base64UrlEncode(random_bytes($bytes));
}

function tokenHash(string $token): string
{
    return hash('sha256', $token);
}

function now(): DateTimeImmutable
{
    return new DateTimeImmutable('now', new DateTimeZone('UTC'));
}

function expiresIn(int $seconds): string
{
    return now()->modify('+' . $seconds . ' seconds')->format('Y-m-d H:i:s');
}

function jsonResponse(array $payload, int $status = 200): void
{
    http_response_code($status);
    header('Content-Type: application/json; charset=UTF-8');
    header('Cache-Control: no-store');
    header('Pragma: no-cache');

    echo json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
}

Use random_bytes() for codes and tokens. Use hash_equals() when comparing secrets or derived challenges.

PKCE helpers

Clients create a code_verifier and send only a derived code_challenge to the authorization endpoint.

function createPkceVerifier(): string
{
    return randomToken(32);
}

function createPkceChallenge(string $codeVerifier): string
{
    return base64UrlEncode(hash('sha256', $codeVerifier, true));
}

function isValidCodeVerifier(string $value): bool
{
    return preg_match('/^[A-Za-z0-9._~-]{43,128}$/', $value) === 1;
}

function verifyPkce(string $codeVerifier, string $expectedChallenge): bool
{
    if (! isValidCodeVerifier($codeVerifier)) {
        return false;
    }

    $actualChallenge = createPkceChallenge($codeVerifier);

    return hash_equals($expectedChallenge, $actualChallenge);
}

For this implementation, accept only S256.

The original PKCE RFC allows plain for compatibility, but current OAuth security guidance treats S256 as the practical baseline because it does not expose the verifier in the authorization request.

Client registration

Register clients out of band. Do not let an authorization request invent its own redirect URI.

final readonly class OAuthClient
{
    /**
     * @param list<string> $redirectUris
     * @param list<string> $allowedScopes
     */
    public function __construct(
        public string $id,
        public string $name,
        public bool $isConfidential,
        public ?string $secretHash,
        public array $redirectUris,
        public array $allowedScopes,
    ) {
    }

    public function allowsRedirectUri(string $redirectUri): bool
    {
        return in_array($redirectUri, $this->redirectUris, true);
    }

    /**
     * @param list<string> $requestedScopes
     */
    public function allowsScopes(array $requestedScopes): bool
    {
        return $requestedScopes === array_values(array_intersect($requestedScopes, $this->allowedScopes));
    }
}

[IMAGE: Supporting visual 1 for Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch, showing Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch decisions, examples, and PHP, OAuth 2.0, PKCE. Alt: Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch implementing-oauth-20-php-authorization-code-flow-from-scratch visual 1]

[IMAGE: Supporting visual 1 for Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch, showing Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch decisions, examples, and PHP, OAuth 2.0, PKCE. Alt: Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch implementing-oauth-20-php-authorization-code-flow-from-scratch visual 1]

Exact redirect URI matching prevents an attacker from swapping the callback target and stealing the authorization code.

Bad:

str_starts_with($redirectUri, 'https://client.example.com');

Good:

$client->allowsRedirectUri($redirectUri);

Do not allow wildcard redirect URIs for web clients.

Authorization endpoint

The authorization endpoint receives the browser redirect:

GET /oauth/authorize
  ?response_type=code
  &client_id=acme-dashboard
  &redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
  &scope=profile%20orders.read
  &state=client-random-state
  &code_challenge=...
  &code_challenge_method=S256

Validation order matters. If the client or redirect URI is invalid, render a local error page. Do not redirect to the supplied URI.

function authorize(PDO $db, ClientRepository $clients, int $userId): void
{
    $responseType = $_GET['response_type'] ?? '';
    $clientId = $_GET['client_id'] ?? '';
    $redirectUri = $_GET['redirect_uri'] ?? '';
    $scope = trim((string) ($_GET['scope'] ?? ''));
    $state = (string) ($_GET['state'] ?? '');
    $codeChallenge = (string) ($_GET['code_challenge'] ?? '');
    $codeChallengeMethod = (string) ($_GET['code_challenge_method'] ?? '');

    if ($responseType !== 'code') {
        renderOAuthError('unsupported_response_type');
        return;
    }

    $client = $clients->find($clientId);

    if (! $client instanceof OAuthClient || ! $client->allowsRedirectUri($redirectUri)) {
        renderOAuthError('invalid_request');
        return;
    }

    $requestedScopes = $scope === '' ? [] : explode(' ', $scope);

    if (! $client->allowsScopes($requestedScopes)) {
        redirectWithError($redirectUri, 'invalid_scope', $state);
        return;
    }

    if ($codeChallenge === '' || $codeChallengeMethod !== 'S256') {
        redirectWithError($redirectUri, 'invalid_request', $state);
        return;
    }

    if (preg_match('/^[A-Za-z0-9_-]{43}$/', $codeChallenge) !== 1) {
        redirectWithError($redirectUri, 'invalid_request', $state);
        return;
    }

    if (! userHasApprovedClient($userId, $client->id, $requestedScopes)) {
        renderConsentScreen($client, $requestedScopes);
        return;
    }

    $code = randomToken(32);

    $statement = $db->prepare(
        'INSERT INTO oauth_authorization_codes
            (code_hash, client_id, user_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at, created_at)
         VALUES
            (:code_hash, :client_id, :user_id, :redirect_uri, :scope, :code_challenge, :method, :expires_at, :created_at)'
    );

    $statement->execute([
        'code_hash' => tokenHash($code),
        'client_id' => $client->id,
        'user_id' => $userId,
        'redirect_uri' => $redirectUri,
        'scope' => implode(' ', $requestedScopes),
        'code_challenge' => $codeChallenge,
        'method' => 'S256',
        'expires_at' => expiresIn(300),
        'created_at' => now()->format('Y-m-d H:i:s'),
    ]);

    redirectWithCode($redirectUri, $code, $state);
}

The code expires in five minutes here. RFC 6749 recommends a maximum authorization code lifetime of ten minutes. Shorter is better when the user experience allows it.

Redirect helpers:

function redirectWithCode(string $redirectUri, string $code, string $state): void
{
    $params = ['code' => $code];

    if ($state !== '') {
        $params['state'] = $state;
    }

    header('Location: ' . appendQuery($redirectUri, $params), true, 302);
    exit;
}

function redirectWithError(string $redirectUri, string $error, string $state): void
{
    $params = ['error' => $error];

    if ($state !== '') {
        $params['state'] = $state;
    }

    header('Location: ' . appendQuery($redirectUri, $params), true, 302);
    exit;
}

function appendQuery(string $uri, array $params): string
{
    $separator = str_contains($uri, '?') ? '&' : '?';

    return $uri . $separator . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
}

Client-side authorization request

The client must generate and store state and code_verifier before redirecting the browser.

session_start();

$_SESSION['oauth_state'] = randomToken(32);
$_SESSION['oauth_code_verifier'] = createPkceVerifier();

$query = http_build_query([
    'response_type' => 'code',
    'client_id' => 'acme-dashboard',
    'redirect_uri' => 'https://client.example.com/oauth/callback',
    'scope' => 'profile orders.read',
    'state' => $_SESSION['oauth_state'],
    'code_challenge' => createPkceChallenge($_SESSION['oauth_code_verifier']),
    'code_challenge_method' => 'S256',
], '', '&', PHP_QUERY_RFC3986);

header('Location: https://auth.example.com/oauth/authorize?' . $query, true, 302);
exit;

On callback, verify state before exchanging the code:

session_start();

$state = (string) ($_GET['state'] ?? '');

if (! hash_equals($_SESSION['oauth_state'] ?? '', $state)) {
    http_response_code(400);
    exit('Invalid OAuth state.');
}

state protects the client from cross-site request forgery and response mix-ups. PKCE does not replace state.

Token endpoint

The token endpoint is a server-to-server POST:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=...
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
&client_id=acme-dashboard
&code_verifier=...

Handle two grants:

  • authorization_code
  • refresh_token
function tokenEndpoint(PDO $db, ClientRepository $clients): void
{
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        jsonResponse(['error' => 'invalid_request'], 405);
        return;
    }

    $grantType = (string) ($_POST['grant_type'] ?? '');
    $client = authenticateClient($clients);

    if (! $client instanceof OAuthClient) {
        jsonResponse(['error' => 'invalid_client'], 401);
        return;
    }

    if ($grantType === 'authorization_code') {
        exchangeAuthorizationCode($db, $client);
        return;
    }

    if ($grantType === 'refresh_token') {
        refreshAccessToken($db, $client);
        return;
    }

    jsonResponse(['error' => 'unsupported_grant_type'], 400);
}

Client authentication:

function authenticateClient(ClientRepository $clients): ?OAuthClient
{
    $clientId = (string) ($_POST['client_id'] ?? '');
    $clientSecret = (string) ($_POST['client_secret'] ?? '');

    if (isset($_SERVER['PHP_AUTH_USER'])) {
        $clientId = (string) $_SERVER['PHP_AUTH_USER'];
        $clientSecret = (string) ($_SERVER['PHP_AUTH_PW'] ?? '');
    }

    $client = $clients->find($clientId);

    if (! $client instanceof OAuthClient) {
        return null;
    }

    if (! $client->isConfidential) {
        return $client;
    }

    if ($clientSecret === '' || $client->secretHash === null) {
        return null;
    }

    return password_verify($clientSecret, $client->secretHash) ? $client : null;
}

Confidential clients authenticate. Public clients cannot keep a secret, so PKCE and redirect URI binding do the heavy lifting.

Exchange the authorization code

The token endpoint must validate:

  • Code exists.
  • Code is not expired.
  • Code is not consumed.
  • Code belongs to this client.
  • redirect_uri matches exactly.
  • PKCE verifier matches the stored challenge.

Do it inside a transaction so two requests cannot redeem the same code.

function exchangeAuthorizationCode(PDO $db, OAuthClient $client): void
{
    $code = (string) ($_POST['code'] ?? '');
    $redirectUri = (string) ($_POST['redirect_uri'] ?? '');
    $codeVerifier = (string) ($_POST['code_verifier'] ?? '');

    if ($code === '' || $redirectUri === '' || $codeVerifier === '') {
        jsonResponse(['error' => 'invalid_request'], 400);
        return;
    }

    $db->beginTransaction();

    try {
        $statement = $db->prepare(
            'SELECT * FROM oauth_authorization_codes WHERE code_hash = :hash FOR UPDATE'
        );
        $statement->execute(['hash' => tokenHash($code)]);
        $authCode = $statement->fetch(PDO::FETCH_ASSOC);

        if (! is_array($authCode)) {
            $db->rollBack();
            jsonResponse(['error' => 'invalid_grant'], 400);
            return;
        }

        if ($authCode['client_id'] !== $client->id || $authCode['redirect_uri'] !== $redirectUri) {
            $db->rollBack();
            jsonResponse(['error' => 'invalid_grant'], 400);
            return;
        }

        if ($authCode['consumed_at'] !== null) {
            // Log this as possible authorization code replay.
            $db->rollBack();
            jsonResponse(['error' => 'invalid_grant'], 400);
            return;
        }

        if (strtotime((string) $authCode['expires_at']) <= time()) {
            $db->rollBack();
            jsonResponse(['error' => 'invalid_grant'], 400);
            return;
        }

        if ($authCode['code_challenge_method'] !== 'S256'
            || ! verifyPkce($codeVerifier, (string) $authCode['code_challenge'])
        ) {
            $db->rollBack();
            jsonResponse(['error' => 'invalid_grant'], 400);
            return;
        }

        $db->prepare(
            'UPDATE oauth_authorization_codes SET consumed_at = :now WHERE code_hash = :hash'
        )->execute([
            'now' => now()->format('Y-m-d H:i:s'),
            'hash' => tokenHash($code),
        ]);

        $tokens = issueTokens(
            db: $db,
            clientId: $client->id,
            userId: (int) $authCode['user_id'],
            scope: (string) $authCode['scope'],
            refreshFamilyId: bin2hex(random_bytes(16)),
        );

        $db->commit();

        jsonResponse($tokens);
    } catch (Throwable $exception) {
        $db->rollBack();
        throw $exception;
    }
}

Do not return detailed reasons for invalid_grant. Detailed logs are for the server. Error responses are for the client.

Issue tokens

This implementation uses opaque bearer tokens. The resource server hashes the presented token and looks it up.

function issueTokens(PDO $db, string $clientId, int $userId, string $scope, string $refreshFamilyId): array
{
    $accessToken = randomToken(32);
    $refreshToken = randomToken(48);
    $createdAt = now()->format('Y-m-d H:i:s');

    $db->prepare(
        'INSERT INTO oauth_access_tokens
            (token_hash, client_id, user_id, scope, expires_at, created_at)
         VALUES
            (:hash, :client_id, :user_id, :scope, :expires_at, :created_at)'
    )->execute([
        'hash' => tokenHash($accessToken),
        'client_id' => $clientId,
        'user_id' => $userId,
        'scope' => $scope,
        'expires_at' => expiresIn(900),
        'created_at' => $createdAt,
    ]);

    $db->prepare(
        'INSERT INTO oauth_refresh_tokens
            (token_hash, family_id, client_id, user_id, scope, expires_at, created_at)
         VALUES
            (:hash, :family_id, :client_id, :user_id, :scope, :expires_at, :created_at)'
    )->execute([
        'hash' => tokenHash($refreshToken),
        'family_id' => $refreshFamilyId,
        'client_id' => $clientId,
        'user_id' => $userId,
        'scope' => $scope,
        'expires_at' => expiresIn(60 * 60 * 24 * 30),
        'created_at' => $createdAt,
    ]);

    return [
        'access_token' => $accessToken,
        'token_type' => 'Bearer',
        'expires_in' => 900,
        'refresh_token' => $refreshToken,
        'scope' => $scope,
    ];
}

Opaque tokens are simple to revoke. JWT access tokens are possible, but they move validation and revocation trade-offs to every resource server.

Refresh token rotation

Refresh token rotation issues a new refresh token every time the old one is used. The old token is revoked.

If an already revoked refresh token is used again, assume token reuse and revoke the whole token family.

function refreshAccessToken(PDO $db, OAuthClient $client): void
{
    $refreshToken = (string) ($_POST['refresh_token'] ?? '');

    if ($refreshToken === '') {
        jsonResponse(['error' => 'invalid_request'], 400);
        return;
    }

    $hash = tokenHash($refreshToken);
    $db->beginTransaction();

    try {
        $statement = $db->prepare(
            'SELECT * FROM oauth_refresh_tokens WHERE token_hash = :hash FOR UPDATE'
        );
        $statement->execute(['hash' => $hash]);
        $stored = $statement->fetch(PDO::FETCH_ASSOC);

        if (! is_array($stored) || $stored['client_id'] !== $client->id) {
            $db->rollBack();
            jsonResponse(['error' => 'invalid_grant'], 400);
            return;
        }

        if ($stored['revoked_at'] !== null) {
            revokeRefreshTokenFamily($db, (string) $stored['family_id']);
            $db->commit();
            jsonResponse(['error' => 'invalid_grant'], 400);
            return;
        }

        if (strtotime((string) $stored['expires_at']) <= time()) {
            $db->prepare(
                'UPDATE oauth_refresh_tokens SET revoked_at = :now WHERE token_hash = :hash'
            )->execute([
                'now' => now()->format('Y-m-d H:i:s'),
                'hash' => $hash,
            ]);

            $db->commit();
            jsonResponse(['error' => 'invalid_grant'], 400);
            return;
        }

        $newAccessToken = randomToken(32);
        $newRefreshToken = randomToken(48);
        $newRefreshHash = tokenHash($newRefreshToken);
        $createdAt = now()->format('Y-m-d H:i:s');

        $db->prepare(
            'UPDATE oauth_refresh_tokens
             SET revoked_at = :now, replaced_by_hash = :new_hash
             WHERE token_hash = :old_hash'
        )->execute([
            'now' => $createdAt,
            'new_hash' => $newRefreshHash,
            'old_hash' => $hash,
        ]);

        $db->prepare(
            'INSERT INTO oauth_access_tokens
                (token_hash, client_id, user_id, scope, expires_at, created_at)
             VALUES
                (:hash, :client_id, :user_id, :scope, :expires_at, :created_at)'
        )->execute([
            'hash' => tokenHash($newAccessToken),
            'client_id' => $client->id,
            'user_id' => (int) $stored['user_id'],
            'scope' => (string) $stored['scope'],
            'expires_at' => expiresIn(900),
            'created_at' => $createdAt,
        ]);

        $db->prepare(
            'INSERT INTO oauth_refresh_tokens
                (token_hash, family_id, client_id, user_id, scope, expires_at, created_at)
             VALUES
                (:hash, :family_id, :client_id, :user_id, :scope, :expires_at, :created_at)'
        )->execute([
            'hash' => $newRefreshHash,
            'family_id' => (string) $stored['family_id'],
            'client_id' => $client->id,
            'user_id' => (int) $stored['user_id'],
            'scope' => (string) $stored['scope'],
            'expires_at' => expiresIn(60 * 60 * 24 * 30),
            'created_at' => $createdAt,
        ]);

        $db->commit();

        jsonResponse([
            'access_token' => $newAccessToken,
            'token_type' => 'Bearer',
            'expires_in' => 900,
            'refresh_token' => $newRefreshToken,
            'scope' => (string) $stored['scope'],
        ]);
    } catch (Throwable $exception) {
        $db->rollBack();
        throw $exception;
    }
}

function revokeRefreshTokenFamily(PDO $db, string $familyId): void
{
    $db->prepare(
        'UPDATE oauth_refresh_tokens
         SET revoked_at = COALESCE(revoked_at, :now)
         WHERE family_id = :family_id'
    )->execute([
        'now' => now()->format('Y-m-d H:i:s'),
        'family_id' => $familyId,
    ]);
}

[IMAGE: Supporting visual 2 for Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch, showing Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch decisions, examples, and PHP, OAuth 2.0, PKCE. Alt: Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch implementing-oauth-20-php-authorization-code-flow-from-scratch visual 2]

This is not only cleanup. It is replay detection.

If a legitimate client and an attacker both hold the same refresh token, the first one rotates it. The second one exposes the compromise by trying to use a revoked token.

Validate bearer tokens

[IMAGE: Supporting visual 2 for Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch, showing Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch decisions, examples, and PHP, OAuth 2.0, PKCE. Alt: Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch implementing-oauth-20-php-authorization-code-flow-from-scratch visual 2]

Resource requests use the Authorization header:

Authorization: Bearer access-token-value

Validation:

function bearerTokenFromRequest(): ?string
{
    $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';

    if (! is_string($header) || ! str_starts_with($header, 'Bearer ')) {
        return null;
    }

    return trim(substr($header, 7));
}

function requireAccessToken(PDO $db, string $requiredScope): array
{
    $token = bearerTokenFromRequest();

    if ($token === null) {
        header('WWW-Authenticate: Bearer realm="api"');
        http_response_code(401);
        exit;
    }

    $statement = $db->prepare(
        'SELECT * FROM oauth_access_tokens WHERE token_hash = :hash'
    );
    $statement->execute(['hash' => tokenHash($token)]);
    $stored = $statement->fetch(PDO::FETCH_ASSOC);

    if (! is_array($stored)
        || $stored['revoked_at'] !== null
        || strtotime((string) $stored['expires_at']) <= time()
    ) {
        header('WWW-Authenticate: Bearer realm="api", error="invalid_token"');
        http_response_code(401);
        exit;
    }

    $scopes = explode(' ', (string) $stored['scope']);

    if (! in_array($requiredScope, $scopes, true)) {
        header('WWW-Authenticate: Bearer realm="api", error="insufficient_scope"');
        http_response_code(403);
        exit;
    }

    return $stored;
}

Bearer tokens are bearer credentials. Anyone with the token can use it. Do not put access tokens in query strings, logs, browser history, exception reports, or analytics payloads.

Revocation endpoint

Add revocation for logout, disconnect, and incident response.

function revokeToken(PDO $db, ClientRepository $clients): void
{
    $client = authenticateClient($clients);

    if (! $client instanceof OAuthClient) {
        jsonResponse(['error' => 'invalid_client'], 401);
        return;
    }

    $token = (string) ($_POST['token'] ?? '');
    $tokenTypeHint = (string) ($_POST['token_type_hint'] ?? '');

    if ($token === '') {
        jsonResponse(['error' => 'invalid_request'], 400);
        return;
    }

    $hash = tokenHash($token);
    $now = now()->format('Y-m-d H:i:s');

    if ($tokenTypeHint === 'refresh_token' || $tokenTypeHint === '') {
        $db->prepare(
            'UPDATE oauth_refresh_tokens
             SET revoked_at = COALESCE(revoked_at, :now)
             WHERE token_hash = :hash AND client_id = :client_id'
        )->execute([
            'now' => $now,
            'hash' => $hash,
            'client_id' => $client->id,
        ]);
    }

    if ($tokenTypeHint === 'access_token' || $tokenTypeHint === '') {
        $db->prepare(
            'UPDATE oauth_access_tokens
             SET revoked_at = COALESCE(revoked_at, :now)
             WHERE token_hash = :hash AND client_id = :client_id'
        )->execute([
            'now' => $now,
            'hash' => $hash,
            'client_id' => $client->id,
        ]);
    }

    jsonResponse([]);
}

RFC 7009 defines OAuth token revocation. A revocation endpoint should be boring: authenticate the client, revoke if found, and avoid leaking whether the token existed.

Security checklist

Before calling this production-ready, verify:

  • All OAuth endpoints require HTTPS.
  • Authorization endpoint prevents clickjacking with frame-ancestors or X-Frame-Options.
  • Redirect URIs are registered and matched exactly.
  • Invalid client or redirect URI requests are not redirected.
  • Authorization codes are single-use and expire quickly.
  • PKCE S256 is required.
  • state is required by clients and verified on callback.
  • Token endpoint uses POST only.
  • Token responses include Cache-Control: no-store and Pragma: no-cache.
  • Raw codes, access tokens, and refresh tokens are never stored.
  • Refresh tokens are rotated.
  • Reuse of a revoked refresh token revokes the family.
  • Scopes are allowlisted per client.
  • Consent records are auditable.
  • Logs never contain raw token values.
  • Token endpoints are rate-limited.
  • Client secrets are hashed, not encrypted.
  • Access tokens are short-lived.
  • Refresh tokens have inactivity and absolute expiry.
  • Token revocation exists.

If you are implementing login, add OpenID Connect instead of treating OAuth access tokens as proof of identity.

What not to build

Do not add these for a new web app:

  • Implicit grant.
  • Password grant.
  • Wildcard redirect URIs.
  • Tokens in URL query strings.
  • Long-lived access tokens.
  • Refresh tokens in browser local storage.
  • Client secrets in JavaScript.
  • Dynamic client registration without a threat model.
  • JWT access tokens without a key rotation and revocation story.

[IMAGE: Supporting visual 3 for Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch, showing Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch decisions, examples, and PHP, OAuth 2.0, PKCE. Alt: Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch implementing-oauth-20-php-authorization-code-flow-from-scratch visual 3]

The authorization code flow with PKCE is the path to make boring and correct.

FAQ

What is Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch?

Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch is a practical security topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.

When should a team use Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch?

Use Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.

What is the biggest risk with Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch?

The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.

How do you test Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch?

Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.

How does Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch affect SEO and AI search visibility?

It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.

Conclusion

Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.

Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.

Top