SEO Metadata
SEO Title Options
- Implementing OAuth 2.0 in PHP: Authorization Code Flow
- Implementing OAuth 2.0 in PHP: Authorizati: Practical 2026
- Security Playbook: Implementing OAuth 2.0 in PHP
Meta Description Options
- Learn Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch with a practical Security framework, expert mistakes, implementation steps.
- 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
- What Implementing OAuth 2.0 in PHP: Authorization Code Flow From Scratch means
- Why it matters now
- Implementation framework
- Practical comparison
- Expert workflow
- Common mistakes
- Media and link plan
- Original technical deep dive
- FAQ
- Structured data
- Conclusion
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.
- Define the user problem and the production risk.
- Identify the smallest reliable implementation boundary.
- Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
- Add tests for the behavior that would hurt if it regressed.
- Document the trade-off, not only the final code.
- Measure the result with logs, metrics, or user-facing outcomes.
- 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 area | Strong approach | Weak approach | Why it matters |
|---|---|---|---|
| Scope | Solve one clear problem | Mix unrelated concerns | Focus improves testing and search intent |
| Architecture | Put logic in explicit classes or documented boundaries | Hide behavior in templates or incidental callbacks | Future changes stay easier to review |
| Data flow | Pass prepared data into the view or endpoint | Query or compute in presentation code | Reduces regressions and performance surprises |
| Testing | Cover the risky behavior directly | Test only the happy path | Catches production failures earlier |
| Documentation | Explain trade-offs and limits | Repeat generic definitions | Builds E-E-A-T and reader trust |
| Operations | Track logs, metrics, and rollback steps | Ship without measurement | Makes 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]
Media and link plan
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.]
Trustworthy outbound links
- PHP manual - use this as the trust reference for language-level reference.
- Google Search quality guidance - use this as the trust reference for people-first content and E-E-A-T alignment.
Internal linking opportunities
- Internal guide: How to Implement JWT Authentication in Pure - use this when readers need a related Security follow-up.
- Internal guide: Secure PHP File Uploads: Validation, Storage - use this when readers need a related Security follow-up.
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
S256verification. - 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, notplain. - 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_uriused 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:
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_coderefresh_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_urimatches 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-ancestorsorX-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
S256is required. stateis required by clients and verified on callback.- Token endpoint uses POST only.
- Token responses include
Cache-Control: no-storeandPragma: 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.