Build Your Own OAuth2 Identity Provider
A complete, practical guide to building a central IDP in pure PHP and MySQL — no framework, no library avalanche. One auth server that gives SSO access to all your websites, built on security best practices from the ground up.
OAuth2 Concepts You Must Understand
OAuth2 is an authorization framework (RFC 6749). When combined with OpenID Connect (OIDC) it also handles authentication. Your IDP will implement the Authorization Code Flow — the only flow you should use for web apps today.
The Four Roles
| Role | Who it is in your setup |
|---|---|
| Resource Owner | Your end-user (the human logging in) |
| Client | Your website requesting access (e.g. shop.example.com) |
| Authorization Server | Your IDP (auth.example.com) — what we're building |
| Resource Server | An API protected by tokens (can be the same app as the client) |
Tokens at a Glance
| Token | What it is | Lifetime |
|---|---|---|
| Authorization Code | One-time code exchanged for tokens. Never stored long-term. | 60–120 s |
| Access Token | Bearer credential to call APIs. Opaque string or JWT. | 15–60 min |
| Refresh Token | Long-lived. Used to get new access tokens silently. | Days / weeks |
| ID Token | JWT with user identity claims. Part of OIDC, not raw OAuth2. | Same as access token |
Never use the Implicit Flow — it was deprecated in OAuth 2.1. Authorization Code + PKCE replaces it for all client types, including SPAs and mobile apps.
Architecture Overview
We keep things radically simple: one PHP app, one MySQL database, no Composer autoloader required.
All files live under auth.example.com/. Only the public/ directory is
exposed
as the webroot — everything else is protected above it.
auth.example.com/
├── public/
│ ├── authorize.php # Authorization endpoint
│ ├── token.php # Token endpoint
│ ├── userinfo.php # UserInfo endpoint (OIDC)
│ ├── login.php # Login form
│ └── .htaccess # HTTPS enforcement + security headers
├── src/
│ ├── DB.php # PDO singleton wrapper
│ ├── Crypto.php # Token generation, PKCE, JWT signing
│ └── Auth.php # User authentication logic
└── config.php # Secrets + DB creds (never in webroot)
Only serve public/ as your webroot. The src/ directory and
config.php should sit above the webroot, or be blocked by
.htaccess, to prevent direct HTTP access.
Database Schema
Five tables cover everything. Use InnoDB, utf8mb4, and foreign keys.
Indexes on expires_at columns keep the cleanup cron fast.
-- Users
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(320) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL, -- bcrypt hash only
name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- OAuth Clients (your registered websites)
CREATE TABLE oauth_clients (
client_id VARCHAR(80) NOT NULL,
client_secret VARCHAR(255) NOT NULL, -- bcrypt hashed!
name VARCHAR(255) NOT NULL,
redirect_uris TEXT NOT NULL, -- JSON array of allowed URIs
scopes VARCHAR(512) DEFAULT 'openid profile email',
is_public TINYINT DEFAULT 0, -- 1 = SPA/mobile (PKCE only)
PRIMARY KEY (client_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Authorization Codes (very short-lived)
CREATE TABLE oauth_auth_codes (
code VARCHAR(128) NOT NULL,
client_id VARCHAR(80) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
redirect_uri VARCHAR(2048) NOT NULL,
scopes VARCHAR(512),
code_challenge VARCHAR(256), -- PKCE S256 challenge
expires_at TIMESTAMP NOT NULL,
used TINYINT DEFAULT 0,
PRIMARY KEY (code),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Access Tokens
CREATE TABLE oauth_access_tokens (
token VARCHAR(128) NOT NULL,
client_id VARCHAR(80) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
scopes VARCHAR(512),
expires_at TIMESTAMP NOT NULL,
revoked TINYINT DEFAULT 0,
PRIMARY KEY (token),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Refresh Tokens
CREATE TABLE oauth_refresh_tokens (
token VARCHAR(128) NOT NULL,
access_token VARCHAR(128) NOT NULL,
client_id VARCHAR(80) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
scopes VARCHAR(512),
expires_at TIMESTAMP NOT NULL,
revoked TINYINT DEFAULT 0,
PRIMARY KEY (token),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Indexes for fast expiry cleanup
CREATE INDEX idx_auth_expires ON oauth_auth_codes(expires_at);
CREATE INDEX idx_access_expires ON oauth_access_tokens(expires_at);
CREATE INDEX idx_refresh_expires ON oauth_refresh_tokens(expires_at);
Security Primitives & Helpers
These two files underpin all cryptographic operations. Read them carefully before touching anything else — they are the foundation of the entire system.
DB.php — PDO Wrapper
<?php
declare(strict_types=1);
class DB
{
private static ?PDO $pdo = null;
public static function connection(): PDO
{
if (self::$pdo === null) {
$cfg = require __DIR__ . '/../config.php';
self::$pdo = new PDO(
"mysql:host={$cfg['db_host']};dbname={$cfg['db_name']};charset=utf8mb4",
$cfg['db_user'],
$cfg['db_pass'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
return self::$pdo;
}
public static function query(string $sql, array $params = []): PDOStatement
{
$stmt = self::connection()->prepare($sql);
$stmt->execute($params);
return $stmt;
}
}
Crypto.php — Token Generation, PKCE & JWT
<?php
declare(strict_types=1);
class Crypto
{
/** Cryptographically secure random token. 32 bytes = 256 bits of entropy. */
public static function generateToken(int $bytes = 32): string
{
return bin2hex(random_bytes($bytes));
}
/** Timing-safe comparison. ALWAYS use instead of === for secrets. */
public static function safeEquals(string $a, string $b): bool
{
return hash_equals($a, $b);
}
/** PKCE: verify code_verifier against stored S256 challenge. */
public static function verifyPKCE(string $verifier, string $challenge): bool
{
$computed = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return hash_equals($challenge, $computed);
}
/** Minimal HS256 JWT — no external library needed. */
public static function createJWT(array $payload, string $secret): string
{
$header = self::b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$body = self::b64url(json_encode($payload));
$sig = self::b64url(hash_hmac('sha256', "$header.$body", $secret, true));
return "$header.$body.$sig";
}
public static function verifyJWT(string $token, string $secret): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) return null;
[$h, $b, $s] = $parts;
$expected = self::b64url(hash_hmac('sha256', "$h.$b", $secret, true));
if (!hash_equals($expected, $s)) return null;
$payload = json_decode(self::b64urlDecode($b), true);
if (isset($payload['exp']) && $payload['exp'] < time()) return null;
return $payload;
}
private static function b64url(string $d): string
{
return rtrim(strtr(base64_encode($d), '+/', '-_'), '=');
}
private static function b64urlDecode(string $d): string
{
return base64_decode(strtr($d, '-_', '+/') . str_repeat('=', 3 - (strlen($d) + 3) % 4));
}
}
HS256 uses a shared secret — every service validating the JWT needs it.
RS256 uses a private key to sign (IDP only) and a public key to verify (each service
independently).
For a multi-app IDP, RS256 is strongly preferred. Use openssl_sign() and
openssl_verify() — still no library needed.
Authorization Endpoint
This is the page users land on first. It validates the client, displays a login form, and — after successful authentication — issues a short-lived authorization code.
Never redirect to an unvalidated redirect_uri. If the client is unknown
or the URI doesn't match the exact whitelist, show an error page and stop —
an open redirect here is an authorization code injection vulnerability.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/DB.php';
require_once __DIR__ . '/../src/Crypto.php';
session_start();
// 1. Parse incoming request
$clientId = trim($_GET['client_id'] ?? '');
$redirectUri = trim($_GET['redirect_uri'] ?? '');
$responseType= trim($_GET['response_type'] ?? '');
$scope = trim($_GET['scope'] ?? 'openid');
$state = trim($_GET['state'] ?? '');
$codeChallenge = trim($_GET['code_challenge'] ?? '');
if ($responseType !== 'code') {
http_response_code(400);
die(json_encode(['error' => 'unsupported_response_type']));
}
// 2. Load and validate client — on failure, show error, never redirect
$client = DB::query('SELECT * FROM oauth_clients WHERE client_id = ?', [$clientId])->fetch();
if (!$client) die('Unknown client.');
// 3. Validate redirect_uri — exact match against whitelist only
$allowed = json_decode($client['redirect_uris'], true);
if (!in_array($redirectUri, $allowed, true)) die('Invalid redirect_uri.');
// 4. Require a sufficiently long state to prevent CSRF
if (strlen($state) < 16) {
$q = http_build_query(['error' => 'invalid_request', 'state' => $state]);
header("Location: {$redirectUri}?{$q}"); exit();
}
// 5. Handle login form POST
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Verify CSRF token on the login form itself
if (!Crypto::safeEquals($_POST['csrf'] ?? '', $_SESSION['login_csrf'] ?? '')) {
die('CSRF validation failed.');
}
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
$password = $_POST['password'] ?? '';
if ($email) {
$user = DB::query('SELECT * FROM users WHERE email = ?', [$email])->fetch();
if ($user && password_verify($password, $user['password'])) {
// Authenticated — issue authorization code (TTL: 2 min)
$code = Crypto::generateToken();
$expiresAt = date('Y-m-d H:i:s', time() + 120);
DB::query(
'INSERT INTO oauth_auth_codes
(code, client_id, user_id, redirect_uri, scopes, code_challenge, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)',
[$code, $clientId, $user['id'], $redirectUri, $scope,
$codeChallenge ?: null, $expiresAt]
);
$q = http_build_query(['code' => $code, 'state' => $state]);
header("Location: {$redirectUri}?{$q}"); exit();
}
}
$loginError = 'Invalid email or password.';
}
// 6. Render login form (set a CSRF token for the form POST)
$_SESSION['login_csrf'] = Crypto::generateToken(16);
// Include your HTML login form here, posting back to this same URL
Token Endpoint
This endpoint is called server-to-server only — never from the browser.
It exchanges the authorization code for an access token, refresh token, and OIDC ID token.
The spec requires Cache-Control: no-store on every response.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/DB.php';
require_once __DIR__ . '/../src/Crypto.php';
header('Content-Type: application/json');
header('Cache-Control: no-store'); // Required by RFC 6749
header('Pragma: no-cache');
$cfg = require __DIR__ . '/../config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') err('invalid_request', 405);
$grantType = $_POST['grant_type'] ?? '';
$code = $_POST['code'] ?? '';
$redirectUri = $_POST['redirect_uri'] ?? '';
$codeVerifier = $_POST['code_verifier'] ?? '';
// Authenticate client — prefer HTTP Basic Auth over POST body
if (!empty($_SERVER['PHP_AUTH_USER'])) {
$clientId = $_SERVER['PHP_AUTH_USER'];
$clientSecret = $_SERVER['PHP_AUTH_PW'];
} else {
$clientId = $_POST['client_id'] ?? '';
$clientSecret = $_POST['client_secret'] ?? '';
}
$client = DB::query('SELECT * FROM oauth_clients WHERE client_id = ?', [$clientId])->fetch();
if (!$client) err('invalid_client', 401);
if (!$client['is_public'] && !password_verify($clientSecret, $client['client_secret'])) {
err('invalid_client', 401);
}
if ($grantType !== 'authorization_code') err('unsupported_grant_type');
// Fetch and validate auth code
$authCode = DB::query(
'SELECT * FROM oauth_auth_codes
WHERE code = ? AND client_id = ? AND used = 0 AND expires_at > NOW()',
[$code, $clientId]
)->fetch();
if (!$authCode) err('invalid_grant');
// Mark code as used immediately — prevents replay attacks
DB::query('UPDATE oauth_auth_codes SET used = 1 WHERE code = ?', [$code]);
if ($authCode['redirect_uri'] !== $redirectUri) err('invalid_grant');
// Verify PKCE if a challenge was stored
if ($authCode['code_challenge'] && !Crypto::verifyPKCE($codeVerifier, $authCode['code_challenge'])) {
err('invalid_grant');
}
// Issue tokens
$now = time();
$accessToken = Crypto::generateToken();
$refreshToken = Crypto::generateToken();
$accessExpiry = $now + 3600;
$refreshExpiry= $now + (30 * 86400);
DB::query(
'INSERT INTO oauth_access_tokens (token, client_id, user_id, scopes, expires_at)
VALUES (?, ?, ?, ?, ?)',
[$accessToken, $clientId, $authCode['user_id'], $authCode['scopes'],
date('Y-m-d H:i:s', $accessExpiry)]
);
DB::query(
'INSERT INTO oauth_refresh_tokens (token, access_token, client_id, user_id, scopes, expires_at)
VALUES (?, ?, ?, ?, ?, ?)',
[$refreshToken, $accessToken, $clientId, $authCode['user_id'],
$authCode['scopes'], date('Y-m-d H:i:s', $refreshExpiry)]
);
// Build OIDC ID Token (JWT)
$user = DB::query('SELECT * FROM users WHERE id = ?', [$authCode['user_id']])->fetch();
$idToken = Crypto::createJWT([
'iss' => $cfg['issuer'],
'sub' => (string)$user['id'],
'aud' => $clientId,
'iat' => $now,
'exp' => $accessExpiry,
'email' => $user['email'],
'name' => $user['name'],
], $cfg['jwt_secret']);
echo json_encode([
'access_token' => $accessToken,
'token_type' => 'Bearer',
'expires_in' => 3600,
'refresh_token' => $refreshToken,
'id_token' => $idToken,
'scope' => $authCode['scopes'],
]);
function err(string $code, int $status = 400): never {
http_response_code($status);
echo json_encode(['error' => $code]); exit();
}
UserInfo Endpoint
Client apps call this with a Bearer token to retrieve the user's profile claims.
Only claims matching the granted scopes are returned.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/DB.php';
header('Content-Type: application/json');
// Extract Bearer token from Authorization header
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '';
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) {
http_response_code(401);
header('WWW-Authenticate: Bearer realm="IDP"');
echo json_encode(['error' => 'invalid_token']); exit();
}
$token = DB::query(
'SELECT t.*, u.email, u.name
FROM oauth_access_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.token = ? AND t.revoked = 0 AND t.expires_at > NOW()',
[$m[1]]
)->fetch();
if (!$token) {
http_response_code(401);
echo json_encode(['error' => 'invalid_token']); exit();
}
$scopes = explode(' ', $token['scopes'] ?? '');
$claims = ['sub' => (string)$token['user_id']];
if (in_array('email', $scopes)) $claims['email'] = $token['email'];
if (in_array('profile', $scopes)) $claims['name'] = $token['name'];
echo json_encode($claims);
Client Application (Relying Party)
This is the code you put on every website that delegates login to your IDP. Two files: one to kick off the login, one to handle the callback.
Login Initiation
<?php
session_start();
$state = bin2hex(random_bytes(16));
$codeVerifier = bin2hex(random_bytes(32));
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
$_SESSION['oauth_state'] = $state;
$_SESSION['code_verifier'] = $codeVerifier;
$params = http_build_query([
'response_type' => 'code',
'client_id' => 'shop_client',
'redirect_uri' => 'https://shop.example.com/callback',
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
]);
header('Location: https://auth.example.com/authorize?' . $params);
exit();
Callback Handler
<?php
session_start();
// 1. Verify state — prevents CSRF
if (empty($_GET['state']) || !hash_equals($_SESSION['oauth_state'] ?? '', $_GET['state'])) {
die('State mismatch — possible CSRF.');
}
unset($_SESSION['oauth_state']);
if (isset($_GET['error'])) die('OAuth error: ' . htmlspecialchars($_GET['error']));
// 2. Exchange code for tokens (server-to-server)
$ch = curl_init('https://auth.example.com/token');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'authorization_code',
'code' => $_GET['code'],
'redirect_uri' => 'https://shop.example.com/callback',
'code_verifier' => $_SESSION['code_verifier'],
]),
CURLOPT_USERPWD => 'shop_client:YOUR_CLIENT_SECRET',
CURLOPT_SSL_VERIFYPEER => true, // Never disable this
CURLOPT_TIMEOUT => 10,
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
unset($_SESSION['code_verifier']);
if (isset($response['error'])) die('Token error: ' . $response['error']);
// 3. Fetch user identity from UserInfo endpoint
$ch = curl_init('https://auth.example.com/userinfo');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $response['access_token']],
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_TIMEOUT => 10,
]);
$user = json_decode(curl_exec($ch), true);
curl_close($ch);
// 4. Create local session — regenerate ID to prevent session fixation
session_regenerate_id(true);
$_SESSION['user'] = [
'id' => $user['sub'],
'email' => $user['email'],
'name' => $user['name'],
];
header('Location: /dashboard'); exit();
Refresh Tokens & Rotation
When an access token expires, the client uses the refresh token to silently get a new one without sending the user back to the login page. Always rotate refresh tokens on use — if a stolen token is replayed, both the attacker and the legitimate client will fail, making the theft detectable.
if ($grantType === 'refresh_token') {
$rtStr = $_POST['refresh_token'] ?? '';
$rt = DB::query(
'SELECT * FROM oauth_refresh_tokens
WHERE token = ? AND client_id = ? AND revoked = 0 AND expires_at > NOW()',
[$rtStr, $clientId]
)->fetch();
if (!$rt) err('invalid_grant');
// Rotate: revoke old tokens and issue fresh ones
DB::query('UPDATE oauth_refresh_tokens SET revoked = 1 WHERE token = ?', [$rtStr]);
DB::query('UPDATE oauth_access_tokens SET revoked = 1 WHERE token = ?', [$rt['access_token']]);
// ... then INSERT new access + refresh tokens (same logic as authorization_code grant)
}
Every use of a refresh token revokes it and issues a new one. A stolen token can only be used once before the legitimate client's next request reveals the theft.
Production Hardening
Before going live, lock down your IDP server with the following configuration. Secrets must come from environment variables, never be hardcoded.
config.php
<?php
return [
'db_host' => '127.0.0.1',
'db_name' => 'idp_db',
'db_user' => 'idp_user',
'db_pass' => getenv('IDP_DB_PASS'), // from environment, never hardcoded
'jwt_secret' => getenv('IDP_JWT_SECRET'), // 256-bit random string
'issuer' => 'https://auth.example.com',
];
Security Headers & HTTPS (.htaccess)
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "no-referrer"
Header always set Content-Security-Policy "default-src 'self'; form-action 'self'"
# Block direct access to src/ directory
<Directory "/var/www/html/src">
Require all denied
</Directory>
Expired Token Cleanup (cron)
# Run every hour
0 * * * * mysql -u idp_user -p"$IDP_DB_PASS" idp_db -e "
DELETE FROM oauth_auth_codes WHERE expires_at < NOW();
DELETE FROM oauth_access_tokens WHERE expires_at < NOW();
DELETE FROM oauth_refresh_tokens WHERE expires_at < NOW();
"
Registering a New Client
-- Hash the secret with password_hash() in PHP first, then insert:
INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, scopes)
VALUES (
'shop_client',
'$2y$12$bcrypt_hash_of_your_generated_secret',
'My Shop',
'["https://shop.example.com/callback"]',
'openid profile email'
);
You now have a self-contained, security-first OAuth2 + OIDC IDP in plain PHP/MySQL. Register your client apps, add your login form HTML, and you have SSO across all your websites — zero external libraries, full control.
What to add next
- RS256 JWT signing — generate an RSA key pair with
openssl, sign with the private key on the IDP, verify with the public key on each client. - JWKS endpoint (
/jwks.json) — publish your public key so clients can auto-discover it. - Discovery document (
/.well-known/openid-configuration) — standard OIDC metadata endpoint. - MFA / TOTP — HMAC-based one-time passwords via
hash_hmac('sha1', ...), no library needed. - Consent screen — show requested scopes and let users approve or deny.
- Token introspection (RFC 7662) — lets resource servers validate opaque access tokens.
- Token revocation (RFC 7009) — lets clients revoke tokens on logout.