geek.sonde.uk updated:
PHP MySQL Security

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.

~30 min read PHP 8.1+ / MySQL 8+ Difficulty: Intermediate
01

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
⚠️
Deprecated: Implicit Flow

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.

02

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.

file structure auth.example.com/
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)
ℹ️
Webroot isolation

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.

03

Database Schema

Five tables cover everything. Use InnoDB, utf8mb4, and foreign keys. Indexes on expires_at columns keep the cleanup cron fast.

sql schema.sql
-- 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);
04

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 src/DB.php
<?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 src/Crypto.php
<?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 vs RS256

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.

05

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.

🚨
Critical Security Rule

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 public/authorize.php
<?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
06

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 public/token.php
<?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();
}
07

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 public/userinfo.php
<?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);
08

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 login.php (on shop.example.com)
<?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 callback.php (on shop.example.com)
<?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();
09

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.

php public/token.php — refresh_token grant (add to token.php)
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)
}
Refresh Token Rotation

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.

10

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 config.php (above webroot)
<?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)

apache public/.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)

bash crontab -e
# 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

sql
-- 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're done!

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.