Skip to main content
  1. Languages/
  2. PHP Guides/

Mastering OAuth 2.0 in PHP: A Secure Implementation Guide

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

Introduction
#

In the landscape of modern web development, handling user credentials directly is becoming increasingly rare—and for good reason. Storing passwords comes with a massive liability. Enter OAuth 2.0, the industry-standard protocol for authorization.

Whether you are building a SaaS platform allowing users to “Log in with GitHub” or integrating with a third-party API like Google Workspace or Stripe, understanding the mechanics of OAuth 2.0 is non-negotiable for a senior backend developer.

As we step into 2026, the ecosystem has matured. We aren’t just hacking together cURL requests anymore. We are dealing with strict security requirements, precise state management to prevent CSRF, and arguably the most important flow: the Authorization Code Grant.

In this guide, we will move beyond the basics. We will implement a robust, production-ready OAuth 2.0 client in PHP 8.4 without relying on a monolithic framework like Laravel or Symfony. We will build this from the ground up using Composer packages to understand exactly what is happening under the hood.

What You Will Learn
#

  • The anatomy of the Authorization Code Grant flow.
  • Setting up a professional PHP environment for OAuth.
  • Implementing the “State” parameter to prevent Cross-Site Request Forgery (CSRF).
  • Handling Access Tokens and Refresh Tokens securely.
  • Best practices for error handling and session management.

Prerequisites and Environment Setup
#

Before writing a single line of code, ensure your environment meets modern standards. OAuth requires HTTPS in production, but for local development, we can simulate this.

Requirements:

  • PHP: Version 8.2 or higher (we will use PHP 8.4 syntax features).
  • Composer: For dependency management.
  • A Provider App: You will need a Client ID and Secret from a provider (e.g., GitHub, Google, or LinkedIn). For this tutorial, we will use GitHub as our example provider, but the code applies universally.

1. Project Initialization
#

Let’s create a clean directory structure. We want to separate our public entry points from our logic.

mkdir php-oauth-pro
cd php-oauth-pro
composer init --name="phpdevpro/oauth-demo" --description="OAuth 2.0 Implementation" --type=project --license=MIT

2. Installing Dependencies
#

While you can write OAuth logic using raw curl functions, it is error-prone and hard to maintain. In the professional PHP world, we rely on battle-tested abstractions. We will use the league/oauth2-client (the gold standard) and vlucas/phpdotenv for secure configuration.

composer require league/oauth2-github vlucas/phpdotenv

Note: The league/oauth2-github is a specific provider implementation for the generic league/oauth2-client package.


The Authorization Flow Visualized
#

Before coding, you must visualize the data handshake. If you don’t understand the flow, debugging will be a nightmare.

We are implementing the Authorization Code Grant. This is the most secure method for server-side applications because the Access Token is never exposed to the user’s browser.

sequenceDiagram autonumber participant User participant Browser participant App as PHP Application participant Provider as GitHub (Auth Server) User->>Browser: Clicks "Login with GitHub" Browser->>App: Request Login Page App-->>Browser: Redirect to GitHub (with Client ID & State) Browser->>Provider: User enters credentials Provider-->>Browser: Redirect back to App Callback (with Code & State) Browser->>App: GET /callback.php?code=XYZ&state=ABC rect rgb(30, 30, 35) note right of App: Back-channel Communication App->>App: Verify State (CSRF Check) App->>Provider: POST /token (Exchange Code + Secret) Provider-->>App: Return Access Token App->>Provider: GET /user (with Access Token) Provider-->>App: Return User Profile (JSON) end App->>App: Create Session / Log User In App-->>Browser: Redirect to Dashboard

Key Takeaway: Notice steps 7 through 10 happen entirely on the server (Back-channel). The user never sees the Access Token.


Step 1: Secure Configuration
#

Never hardcode credentials. It’s the quickest way to leak secrets. create a .env file in your root directory.

# .env
GITHUB_CLIENT_ID=your_client_id_here
GITHUB_CLIENT_SECRET=your_client_secret_here
GITHUB_REDIRECT_URI=http://localhost:8000/callback.php
APP_ENV=local

Next, create a bootstrap.php file. This handles the autoloader and environment variable loading. This keeps our actual logic files clean.

<?php
// bootstrap.php

require __DIR__ . '/vendor/autoload.php';

use Dotenv\Dotenv;

session_start();

$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();

// Simple error reporting for dev
if ($_ENV['APP_ENV'] === 'local') {
    ini_set('display_errors', 1);
    ini_set('display_startup_errors', 1);
    error_reporting(E_ALL);
}

Step 2: The Login Entry Point
#

Create index.php. This file initiates the flow. Its job is to construct the authorization URL and redirect the user.

Crucial Security Step: We generate a random state string and save it in the session. When GitHub redirects the user back, we will check if the state matches. If it doesn’t, it’s a CSRF attack.

<?php
// index.php
require 'bootstrap.php';

use League\OAuth2\Client\Provider\Github;

// 1. Initialize the Provider
$provider = new Github([
    'clientId'     => $_ENV['GITHUB_CLIENT_ID'],
    'clientSecret' => $_ENV['GITHUB_CLIENT_SECRET'],
    'redirectUri'  => $_ENV['GITHUB_REDIRECT_URI'],
]);

// 2. Get the Authorization URL
// The library automatically generates the 'state' parameter here.
$authUrl = $provider->getAuthorizationUrl();

// 3. Store State in Session
// We MUST verify this in the callback to prevent CSRF.
$_SESSION['oauth2state'] = $provider->getState();

// 4. Redirect User
header('Location: ' . $authUrl);
exit;

When you run this (we’ll show how later), the user is whisked away to GitHub to approve your app.


Step 3: The Callback Handler
#

This is where the heavy lifting happens. Create callback.php.

This script must:

  1. Check for errors (e.g., user denied access).
  2. Verify the State (Security Check).
  3. Exchange the “Authorization Code” for an “Access Token”.
  4. Fetch the user’s details using that token.
<?php
// callback.php
require 'bootstrap.php';

use League\OAuth2\Client\Provider\Github;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;

$provider = new Github([
    'clientId'     => $_ENV['GITHUB_CLIENT_ID'],
    'clientSecret' => $_ENV['GITHUB_CLIENT_SECRET'],
    'redirectUri'  => $_ENV['GITHUB_REDIRECT_URI'],
]);

// 1. Check for basic errors or empty code
if (!isset($_GET['code'])) {
    // If the provider returned an error
    if (isset($_GET['error'])) {
        exit('Got error: ' . htmlspecialchars($_GET['error'], ENT_QUOTES, 'UTF-8'));
    }
    // No code present? Send them back to start
    header('Location: index.php');
    exit;
}

// 2. CSRF Protection: Verify State
if (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) {
    unset($_SESSION['oauth2state']);
    exit('Invalid state. Potential CSRF attack detected.');
}

// 3. Exchange Code for Access Token
try {
    // This makes the back-channel POST request to GitHub
    $token = $provider->getAccessToken('authorization_code', [
        'code' => $_GET['code']
    ]);

    // 4. Get User Details
    // The provider automatically uses the $token to fetch the user profile
    $user = $provider->getResourceOwner($token);

    // Save user info to session (or DB in a real app)
    $_SESSION['user'] = $user->toArray();
    $_SESSION['access_token'] = $token->getToken();
    
    // Redirect to a protected dashboard
    header('Location: dashboard.php');
    exit;

} catch (IdentityProviderException $e) {
    // Log this error in production!
    exit('OAuth Error: ' . $e->getMessage());
} catch (Exception $e) {
    exit('General Error: ' . $e->getMessage());
}

The Dashboard
#

Just to verify it works, create a simple dashboard.php:

<?php
// dashboard.php
require 'bootstrap.php';

if (!isset($_SESSION['user'])) {
    header('Location: index.php');
    exit;
}

$user = $_SESSION['user'];

// Using Heredoc for clean HTML output
echo <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Welcome</title>
    <style>
        body { font-family: system-ui, sans-serif; padding: 2rem; }
        .card { border: 1px solid #ddd; padding: 1rem; border-radius: 8px; max-width: 400px; }
        img { border-radius: 50%; width: 100px; }
    </style>
</head>
<body>
    <h1>Login Successful!</h1>
    <div class="card">
        <img src="{$user['avatar_url']}" alt="Avatar">
        <h2>Hello, {$user['login']}</h2>
        <p>Email: {$user['email']}</p>
        <p>GitHub ID: {$user['id']}</p>
        <hr>
        <pre>Token: {$_SESSION['access_token']}</pre>
    </div>
</body>
</html>
HTML;

Running the Application
#

To test this locally without setting up Nginx or Apache, use the built-in PHP server.

php -S localhost:8000
  1. Navigate to http://localhost:8000 in your browser.
  2. You will be redirected to GitHub.
  3. Authorize the app.
  4. You will land on dashboard.php with your profile data.

Advanced Considerations for Senior Developers
#

Getting it working is step one. Making it production-ready is step two.

1. Handling Token Expiration (Refresh Tokens)
#

Access tokens are short-lived (usually 1 hour). If your app needs to act on behalf of the user offline or for long sessions, you need a Refresh Token.

Note: GitHub does not standardly return refresh tokens for web flows unless specifically configured, but providers like Google do.

Here is how you handle logic for a refreshing provider:

// Check if token is expired
if ($accessToken->hasExpired()) {
    $newAccessToken = $provider->getAccessToken('refresh_token', [
        'refresh_token' => $existingRefreshToken
    ]);
    
    // Save new tokens to database
    saveToDb($user_id, $newAccessToken->getToken(), $newAccessToken->getRefreshToken());
}

2. State vs. PKCE (Proof Key for Code Exchange)
#

For mobile apps or Single Page Applications (SPAs), state isn’t enough. You need PKCE. However, even for server-side PHP apps, PKCE adds a layer of security preventing code injection attacks.

The league/oauth2-client supports PKCE out of the box in newer versions. It involves hashing a “code verifier” and sending it with the initial request, then verifying it during the token exchange.

3. Database Integration
#

In a real scenario, you shouldn’t just dump user data into $_SESSION. You need to synchronize it with your database.

The Workflow:

  1. Get $user->getId() (The Provider’s ID).
  2. Query your users table: SELECT * FROM users WHERE github_id = ?.
  3. If exists: Log them in. Update their avatar/email if changed.
  4. If not exists: Create a new record in users table, then log them in.

Comparison: DIY vs. Libraries vs. Frameworks
#

Should you write this yourself or use a package? Let’s compare the options.

Feature DIY (cURL) PHP League (Library) Laravel Socialite (Framework)
Simplicity Low (High Verbosity) Medium High
Flexibility Maximum High Medium (Opinionated)
Security Risk High (Easy to miss checks) Low (Audited code) Low
Maintenance You own every bug Community Maintained Community Maintained
Dependencies None (Native) PSR-7, Guzzle Laravel Ecosystem
Best For Learning internals Vanilla PHP / Slim / Symfony Laravel Projects

My Recommendation: For any serious project that isn’t already inside Laravel, use the PHP League packages. They are PSR-compliant, type-safe, and handle edge cases (like different providers implementing the spec slightly differently) for you.


Common Pitfalls and Troubleshooting
#

The “Redirect URI Mismatch” Error
#

This is the #1 error developers face.

  • The Fix: The URL in your code (http://localhost:8000/callback.php) MUST match exactly what you registered in the GitHub/Google Developer Console. Even a trailing slash difference can break it.

Scopes
#

Don’t request too much data.

  • If you only need identity, use scope => ['read:user'].
  • If you ask for repo or write access, users will be suspicious and conversion rates will drop.
  • Define scopes in the provider instantiation:
    $provider = new Github([
        // ... creds
        'scopes' => ['read:user', 'user:email']
    ]);

HTTPS in Localhost
#

Some providers (like Facebook) aggressively reject http:// even on localhost.

  • Solution: Use tools like mkcert to generate locally trusted SSL certificates, or use a tunneling service like Ngrok to expose your local server via HTTPS (ngrok http 8000).

Conclusion
#

Implementing OAuth 2.0 in PHP doesn’t have to be a black box. By separating the initialization, the callback, and the user data handling, you create a system that is secure, audit-friendly, and easy to debug.

We moved beyond simple “Login” scripts and ensured our application checks for CSRF via State validation, handles exceptions gracefully, and uses environment variables for security.

As you integrate this into your production applications, remember that OAuth is not just about authentication; it’s about delegation. Treat the access tokens like the keys to the castle—store them securely, refresh them only when necessary, and always respect the principle of least privilege regarding scopes.

Next Steps:

  • Try implementing a second provider (e.g., Google) and refactor your code to handle multiple providers using a Factory Pattern.
  • Implement a database layer to persist users across sessions.
  • Read up on OIDC (OpenID Connect), which sits on top of OAuth 2.0 to provide standardized identity layers.

Happy Coding!