How to Implement Secure JWT Authentication in Native PHP Without Frameworks

By Admin • Updated September 12, 2026 • 28 Reads
JSON Web Token JWT authentication architecture in native PHP with HMAC SHA256
Native PHP JWT Authentication Implementation

When building single-page applications in React or Vue, mobile apps in Flutter, or headless microservices powered by a PHP backend, traditional session cookies become difficult to manage across cross-origin domains and load-balanced server clusters. JSON Web Tokens (JWT) provide a stateless, cryptographically signed industry standard for user authentication and API authorization.

Many developers believe they must install bloated Composer libraries to use JWTs. In reality, implementing a cryptographically secure, high-performance JWT engine in native PHP requires fewer than 100 lines of clean code. In this comprehensive guide, we will analyze token anatomy, examine common cryptographic pitfalls like algorithm confusion, build a full PHP JWT authentication class from scratch, and implement secure token storage best practices.

1. Anatomy of a JSON Web Token

A JWT is a compact, URL-safe string composed of three distinct segments separated by periods (.):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjo0MiwiZXhwIjoxNzg5MjAwMDAwfQ.c8Z4q8...signature
  • Header: Specifies the cryptographic algorithm (such as HMAC-SHA256) and token type (JWT).
  • Payload: Contains claims about the user (e.g., user_id, role, and expiration timestamp exp).
  • Signature: A cryptographic hash calculated over the header and payload using a secret server key.

2. Cryptographic Security Considerations

When building JWT verification without third-party frameworks, you must safeguard against three critical vulnerabilities:

  1. The "None" Algorithm Exploit: In poorly coded implementations, if an attacker modifies the header to "alg": "none" and removes the signature, the server skips verification. Your class must explicitly reject any token that does not strictly match your designated algorithm (e.g., HS256).
  2. Timing Attacks on Signatures: Standard string comparison (== or ===) stops checking as soon as the first mismatch occurs. Attackers measure execution time down to microseconds to guess the signature character-by-character. Always use PHP's constant-time hash_equals() function.
  3. Secret Key Strength: An HS256 secret key must be at least 256 bits (32 to 64 cryptographically random bytes). Never use short dictionary words.

3. Complete Native PHP JWT Class Implementation

Here is an enterprise-grade, zero-dependency JWT implementation utilizing native PHP cryptography:

<?php
class NativeJWT {
    private static string $secret_key = 'REPLACE_WITH_A_64_CHAR_CRYPTOGRAPHIC_KEY_!';

    // Base64Url encode helper
    private static function base64UrlEncode(string $data): string {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }

    // Base64Url decode helper
    private static function base64UrlDecode(string $data): string {
        return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', 3 - (3 + strlen($data)) % 4));
    }

    // Issue a signed JWT token
    public static function createToken(array $payload_claims, int $expiry_seconds = 7200): string {
        $header = json_encode(['alg' => 'HS256', 'typ' => 'JWT']);

        // Inject standard registered claims
        $payload_claims['iat'] = time();
        $payload_claims['exp'] = time() + $expiry_seconds;
        $payload = json_encode($payload_claims);

        $encoded_header = self::base64UrlEncode($header);
        $encoded_payload = self::base64UrlEncode($payload);

        // Generate binary HMAC-SHA256 signature
        $signature = hash_hmac('sha256', $encoded_header . '.' . $encoded_payload, self::$secret_key, true);
        $encoded_signature = self::base64UrlEncode($signature);

        return $encoded_header . '.' . $encoded_payload . '.' . $encoded_signature;
    }

    // Validate, verify, and decode a JWT token
    public static function verifyToken(string $jwt): ?array {
        $segments = explode('.', $jwt);
        if (count($segments) !== 3) {
            return null; // Malformed token structure
        }

        [$header_b64, $payload_b64, $sig_b64] = $segments;

        // Verify header algorithm to prevent "none" algorithm attacks
        $header = json_decode(self::base64UrlDecode($header_b64), true);
        if (!isset($header['alg']) || $header['alg'] !== 'HS256') {
            return null; // Invalid algorithm
        }

        // Recompute signature using constant secret
        $expected_sig = hash_hmac('sha256', $header_b64 . '.' . $payload_b64, self::$secret_key, true);
        $provided_sig = self::base64UrlDecode($sig_b64);

        // Constant-time comparison to prevent timing attacks
        if (!hash_equals($expected_sig, $provided_sig)) {
            return null; // Signature mismatch / Tampered payload!
        }

        $payload = json_decode(self::base64UrlDecode($payload_b64), true);
        if (!$payload || !isset($payload['exp'])) {
            return null;
        }

        // Check if token has expired
        if ($payload['exp'] < time()) {
            return null; // Token expired
        }

        return $payload;
    }
}

4. Securing an API Endpoint with Bearer Tokens

When client applications make API calls, they transmit the token inside the Authorization: Bearer <token> HTTP header:

<?php
// api/user-profile.php
require_once 'NativeJWT.php';

header('Content-Type: application/json');

$headers = getallheaders();
$auth = $headers['Authorization'] ?? $headers['authorization'] ?? '';

if (!preg_match('/Bearer\s(\S+)/', $auth, $matches)) {
    http_response_code(401);
    echo json_encode(['error' => 'Missing or malformed Authorization header.']);
    exit;
}

$token = $matches[1];
$user_claims = NativeJWT::verifyToken($token);

if (!$user_claims) {
    http_response_code(401);
    echo json_encode(['error' => 'Unauthorized: Invalid or expired token.']);
    exit;
}

// Token is valid! Proceed to return protected data
echo json_encode([
    'status'   => 'success',
    'user_id'  => $user_claims['user_id'],
    'role'     => $user_claims['role']
]);

5. Where to Store JWTs: Cookies vs. LocalStorage

A frequent architectural question is where client applications should store tokens. Storing sensitive JWT tokens in browser localStorage or sessionStorage exposes them to Cross-Site Scripting (XSS) attacks. If any third-party script or dependency contains an XSS flaw, attackers can access localStorage.getItem('token') and impersonate the user.

The Secure Recommendation: Store tokens in an httpOnly, Secure, SameSite=Strict cookie. Because httpOnly cookies are inaccessible to JavaScript, malicious scripts cannot steal them, providing maximum protection for your users.

6. Implementing Refresh Token Rotation (RTR)

To combine high security with seamless user experience, implement a two-token model:

  • Short-Lived Access Token: Valid for 15 minutes. Used for everyday API authorization calls.
  • Long-Lived Refresh Token: Valid for 7 to 30 days. Stored securely in a database table with an associated user ID and family ID.

When the access token expires, the client calls /auth/refresh presenting the refresh token. The server issues a new access token AND a brand new refresh token, invalidating the old one. If an attacker tries to reuse an old refresh token, the server immediately revokes the entire token family, logging out all active sessions for that user.

7. Blacklisting and Token Invalidation with Redis

Because JWTs are stateless, once signed, they remain valid until their expiration timestamp (exp) arrives. If a user logs out or an account is compromised, how do you revoke an active token before it expires?

The industry standard is maintaining an in-memory Token Blacklist in Redis using atomic TTL keys:

<?php
class TokenBlacklist {
    private Redis $redis;

    public function __construct() {
        $this->redis = new Redis();
        $this->redis->connect('127.0.0.1', 6379);
    }

    // Blacklist a token upon user logout until its natural expiration
    public function revokeToken(string $jwtToken, int $expiresAtTimestamp): void {
        $remainingTtl = $expiresAtTimestamp - time();
        if ($remainingTtl > 0) {
            $tokenHash = hash('sha256', $jwtToken);
            $this->redis->setex("jwt_blacklist:" . $tokenHash, $remainingTtl, 'revoked');
        }
    }

    // Check if token has been revoked
    public function isRevoked(string $jwtToken): bool {
        $tokenHash = hash('sha256', $jwtToken);
        return $this->redis->exists("jwt_blacklist:" . $tokenHash) > 0;
    }
}

With this complete architecture—HMAC-SHA256 signing, constant-time validation, httpOnly cookie storage, refresh token rotation, and Redis revocation—your native PHP API possesses enterprise-grade security comparable to bank and enterprise SaaS applications.