In this article
May 11, 2026
May 11, 2026

How to handle JWT in PHP

Everything you need to know to implement and validate JWTs securely in PHP: from signing to verifying with JWKS, with code examples and best practices for both vanilla PHP and Laravel.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

PHP powers a vast portion of the web, and the JWT story in PHP is refreshingly simple: one library dominates the ecosystem, it builds on PHP's native OpenSSL extension, and it integrates cleanly with the PSR standards that modern PHP applications are built on. Whether you are writing a Laravel API, a Symfony service, or a framework-free microservice, the fundamentals are the same.

This guide walks through everything you need to know to safely consume, validate, and work with JWTs in PHP, including HS256 and RS256 verification, JWKS with caching, Laravel middleware integration, the CachedKeySet class for key rotation, and common pitfalls. Let's dive right in.

!!Need to inspect a JWT? Use the WorkOS JWT Debugger to decode and inspect your JWTs directly in the browser. It's a quick way to verify your token's iss, aud, sub, and other claims while debugging.!!

JWT 101

A JSON Web Token is a compact, URL-safe token format used to securely transmit information between systems. At a high level, a JWT lets one system make a signed statement about a user or service, and lets another system verify that statement without needing to look anything up in a database.

They are typically used to indicate a user's identity and/or assert permissions and roles.

A JWT is composed of three parts, each Base64URL-encoded and separated by dots:

  
header.payload.signature
  

Header

The header contains metadata about the token, most importantly the signing algorithm used to create the signature (e.g., HMAC, RSA, or ECDSA). This tells the verifier how the token was signed and how it should be validated.

A typical header before encoding:

  
{
  "alg": "RS256",
  "typ": "JWT"
}
  

In this example, alg is set to RS256, representing RSA with SHA-256, and typ identifies this as a JWT.

Payload

The payload contains the actual data the token encodes. These data points are called claims.

Claims are pieces of information about the subject of the token and additional context about how it should be used. Some claims are registered and standardized, like iss, sub, aud, and exp (for the full list check the JWT claims registry). Others are custom and application-specific.

Example payload:

  
{
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "email": "hgranger@hogwarts.example",
  "roles": ["admin", "editor"],
  "iat": 1716239022,
  "iss": "your-saas-app",
  "aud": "your-api",
  "exp": 1716242622
}
  

It is important to note that the payload is not encrypted. Anyone who has the token can decode it and read the claims. Do not put passwords, secrets, or high-risk PII in JWT payloads.

Signature

The signature ensures the token's integrity and confirms that it was issued by a trusted source. It is created by hashing the Base64URL-encoded header and payload with a secret key (for symmetric algorithms like HS256) or a private key (for asymmetric algorithms like RS256). The resulting hash is then Base64URL-encoded and appended to the token.

When a JWT is received, the verifier recomputes the signature using the appropriate key and compares it to the signature included in the token. If they do not match, the token has been tampered with and must be rejected.

JWTs are protected via JSON Web Signature (JWS). JWS is used to share data between parties when confidentiality is not required, because the claims within a JWS can be read by anyone (they are simply Base64URL-encoded). The signature provides authentication, not encryption. Some of the cryptographic algorithms JWS uses are HMAC, RSA, and ECDSA.

Algorithm Description Use case
HS256 HMAC with shared secret Simple systems, internal services
RS256 RSA with public/private keys Authorization servers, external IdPs
ES256 ECDSA with elliptic-curve keys Modern IdPs, compact signatures

JWT library for PHP

The firebase/php-jwt library is the standard for handling JWTs in PHP. With over 460 million Composer installs, nearly 10,000 GitHub stars, and 2,300+ dependent packages, it is the clear choice. It is actively maintained (v7.0.5 released April 2026), requires PHP 8.0+, and provides support for HMAC, RSA, ECDSA, and EdDSA signing algorithms through PHP's native OpenSSL extension.

The library also provides built-in JWKS support through two classes: JWK for parsing key sets from JSON, and CachedKeySet for fetching and caching JWKS from a remote endpoint with automatic key rotation and rate limiting.

Install it with Composer:

  
composer require firebase/php-jwt
  

For JWKS endpoint fetching with caching, you will also need a PSR-7/PSR-17 HTTP client and a PSR-6 cache implementation. If you are using Laravel, these are already available. For vanilla PHP:

  
composer require guzzlehttp/guzzle guzzlehttp/psr7 phpfastcache/phpfastcache
  

Generating your keys

First, you need a set of cryptographic keys to sign your tokens.

In this tutorial, we will be using RS256. This asymmetric algorithm requires two keys: a private key to sign the token and a public key to verify it. If you already have them, move along to the next section.

!!Asymmetric algorithms use a pair of public and private keys to sign and verify the tokens. They are more secure, scalable, and better for distributed systems but also more resource-intensive and complex. For more on the various algorithms see Which algorithm should you use to sign JWTs?!!

There are many ways to generate your keys. You could generate them using OpenSSL and save them as raw PEM files that your code would read:

  
# Generate private key
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048

# Extract the public key
openssl rsa -pubout -in private_key.pem -out public_key.pem
  

However, this is not a best practice. Instead, you should use JSON Web Key Sets (JWKS), especially in distributed or cloud environments.

!!JWKS vs PEM: JWKS simplifies key rotation by allowing services to fetch the latest keys from a central endpoint, making updates easier and reducing the risk of errors. PEM files require manual distribution and updates, which can be cumbersome in large systems. JWKS centralizes key distribution, ensuring that all services or clients always have the correct keys without constant manual updates.!!

If you are using a third-party identity provider (like WorkOS), they automatically generate and expose a JWKS endpoint for you. This allows clients to dynamically fetch the public keys needed for JWT verification without you having to manage the keys manually. WorkOS offers a public JWKS endpoint:

  
https://api.workos.com/sso/jwks/your-client_id
  

The response looks like this:

  
{
  "keys": [
    {
      "alg": "RS256",
      "kty": "RSA",
      "use": "sig",
      "x5c": [
        "MIIDQj3DQEBCwUA..."
      ],
      "n": "0vx7agoebGc...eKnNs",
      "e": "AQAB",
      "kid": "key_013456789",
      "x5t#S256": "ZjQzYjI0OT...NmNjU0"
    }
  ]
}

  

Clients and APIs can use this endpoint to retrieve the public keys needed to validate JWTs signed by WorkOS. Key rotation, expiration, and distribution are handled automatically by the provider.

If you are not using a third-party identity provider and want to create and manage your own JWKS in Java, you will need to:

  1. Generate a key pair (public and private keys). Java's java.security.KeyPairGenerator provides this natively through the JCA.
  2. Create a JWKS endpoint. Expose the public keys at a well-known URL (/.well-known/jwks.json) that clients and services can use to validate JWTs.
  3. Handle key rotation and management. Periodically generate new key pairs and update the JWKS. Use a key identifier (kid) to distinguish between active and retired keys.
  4. Secure your private keys. Never expose private keys through your API or any public endpoint. Store them in a secure EKM like WorkOS Vault, an HSM, or at minimum an encrypted file with restricted access.

!!If you need something fast for a proof-of-concept, you can use a tool like mkjwk.org to generate a JWK.!!

Generating an RSA key pair in PHP

PHP's OpenSSL extension provides key generation natively:

  
<?php

// Generate an RSA key pair
$config = [
    'private_key_bits' => 2048,
    'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$keyPair = openssl_pkey_new($config);

// Extract the private key as a PEM string
openssl_pkey_export($keyPair, $privateKeyPem);

// Extract the public key
$publicKeyDetails = openssl_pkey_get_details($keyPair);
$publicKeyPem = $publicKeyDetails['key'];

// Save the keys
file_put_contents('/path/to/private_key.pem', $privateKeyPem);
file_put_contents('/path/to/public_key.pem', $publicKeyPem);
  

Store your private key outside the web root with restricted file permissions (e.g., chmod 600). In Laravel, the standard approach is to store PEM content in your .env file or use php artisan env:encrypt for encrypted environment files. We will cover this in the Laravel section.

PHP and newlines in PEM keys

This catches many PHP developers off guard. If you store a PEM key in an environment variable, the \n characters must be interpreted as actual newlines. In PHP, this means using double quotes, not single quotes:

  
// Wrong: \n is treated as literal characters
$key = 'MIIEowIBAAKCAQEA...\nmore key data...\n';

// Correct: \n is interpreted as newlines
$key = "MIIEowIBAAKCAQEA...\nmore key data...\n";

// Also correct: use str_replace
$key = str_replace('\n', "\n", env('JWT_PRIVATE_KEY'));
  

This is one of the most frequently reported issues on the firebase/php-jwt GitHub repository. If your key looks correct but verification fails, check the newlines first.

Creating a JWT with RS256

Once you have your RSA keys, you can create and sign a token using the private key:

  
<?php

use Firebase\JWT\JWT;

$privateKey = file_get_contents('/path/to/private_key.pem');

$payload = [
    'sub' => 'user_123',
    'email' => 'hgranger@hogwarts.example',
    'roles' => ['admin', 'editor'],
    'department' => 'engineering',
    'iss' => 'https://your-app.example.com',
    'aud' => 'your-api',
    'iat' => time(),
    'exp' => time() + (15 * 60), // 15 minutes
];

$headers = [
    'kid' => 'primary',
];

$jwt = JWT::encode($payload, $privateKey, 'RS256', null, $headers);
echo $jwt;
  

The JWT::encode() method takes the payload, the signing key, the algorithm, an optional key ID (fourth parameter, or null to pass it in headers), and optional header claims. The kid in the header is important for key rotation, which we will cover later.

Creating a JWT with HS256

For simpler use cases where a shared secret is acceptable:

  
<?php

use Firebase\JWT\JWT;

$secretKey = 'your-secret-key-at-least-256-bits-long';

$payload = [
    'sub' => 'user_123',
    'iss' => 'https://your-app.example.com',
    'aud' => 'your-api',
    'iat' => time(),
    'exp' => time() + (15 * 60),
];

$jwt = JWT::encode($payload, $secretKey, 'HS256');
  

For HS256, make sure your secret key is long enough. A key shorter than 256 bits (32 bytes) is insecure.

Sending the token as a Bearer token

Once the client has the JWT, it sends it in the Authorization header as a Bearer token. The Bearer prefix tells the API that whoever bears this token can use it:

  
<?php

$jwt = 'eyJhbGciOiJSUzI1NiIs...';

$ch = curl_init('https://api.example.com/protected');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . $jwt,
        'Content-Type: application/json',
    ],
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
curl_close($ch);
  

Or with Guzzle (which you likely already have if you are using JWKS):

  
<?php

use GuzzleHttp\Client;

$client = new Client();
$response = $client->get('https://api.example.com/protected', [
    'headers' => [
        'Authorization' => 'Bearer ' . $jwt,
    ],
]);
  

On the server side, you extract the token from this header before validating it.

Adding standard and custom claims

JWT claims fall into two categories: standard and custom.

Standard claims

Common registered claims include:

  • sub (subject): what the token is about, typically the user's unique identifier.
  • iss (issuer): who issued the token.
  • aud (audience): who the token is intended for.
  • exp (expiration time): when the token expires, in seconds since the Unix epoch.
  • iat (issued at): when the token was issued.
  • nbf (not before): when the token becomes valid.
  • jti (JWT ID): a unique identifier for the token, useful for revocation.

In PHP, these are simply keys in the payload array:

  
$payload = [
    'sub' => 'user_123',
    'iss' => 'https://your-app.example.com',
    'aud' => 'your-api',
    'exp' => time() + 900,
    'iat' => time(),
    'nbf' => time(),
    'jti' => bin2hex(random_bytes(16)),
];
  

Custom claims

Custom claims are application-specific data added alongside the standard claims:

  
$payload = [
    'sub' => 'user_123',
    'email' => 'hgranger@hogwarts.example',
    'roles' => ['admin', 'editor'],
    'department' => 'engineering',
    'email_verified' => true,
    'feature_flags' => ['beta_access' => true, 'dark_mode' => false],
];
  

PHP arrays can hold any JSON-serializable type (strings, numbers, booleans, nested arrays and objects), so you can structure custom claims however you need. Be careful not to include sensitive information, since JWT payloads are encoded, not encrypted.

Decoding a JWT

Decoding a JWT without verifying it can be useful for debugging and logging, but it should never be used for authorization decisions.

The firebase/php-jwt library deliberately does not provide a "decode without verification" method. The README explicitly states that decoding headers without verification is not recommended. If you need to inspect a token for debugging, you can do so manually:

  
<?php

$jwt = 'eyJhbGciOiJSUzI1NiIs...';

// Split the token into its three parts
[$headerB64, $payloadB64, $signatureB64] = explode('.', $jwt);

// Decode the header and payload (NOT verified, do not trust these values)
$header = json_decode(base64_decode(strtr($headerB64, '-_', '+/')), true);
$payload = json_decode(base64_decode(strtr($payloadB64, '-_', '+/')), true);

echo "Algorithm: " . $header['alg'] . "\n";
echo "Key ID: " . ($header['kid'] ?? 'none') . "\n";
echo "Subject: " . $payload['sub'] . "\n";
echo "Email: " . ($payload['email'] ?? 'none') . "\n";
  

The library takes this stance because any value from an unverified token could have been tampered with. Do not use unverified claims for authorization, routing, or database lookups.

About the kid claim

The kid (key ID) appears in the JWT header, not the payload:

  
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "primary"
}
  

It tells your application which public key (from a set of keys) should be used to verify the signature. This is essential when your authentication provider uses key rotation, publishing multiple public keys at a JWKS endpoint and including kid in the JWT header to indicate which key was used to sign it.

When your app receives a JWT, it extracts the kid from the header, looks up the matching public key in the JWKS, and uses that key to verify the signature. The firebase/php-jwt library handles this automatically through JWK::parseKeySet() and CachedKeySet, which we will use in the next section.

Verifying a JWT

Verification ensures three things: the signature is valid, the token has not expired, and the claims match your expectations.

Verifying with a local public key

If you have the public key available locally:

  
<?php

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$publicKey = file_get_contents('/path/to/public_key.pem');

try {
    $decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));

    echo "Subject: " . $decoded->sub . "\n";
    echo "Email: " . $decoded->email . "\n";

} catch (\Firebase\JWT\ExpiredException $e) {
    echo "Token has expired\n";
} catch (\Firebase\JWT\SignatureInvalidException $e) {
    echo "Invalid signature\n";
} catch (\UnexpectedValueException $e) {
    echo "Token verification failed: " . $e->getMessage() . "\n";
}
  

The Key class is how firebase/php-jwt enforces algorithm specificity. By wrapping the key with new Key($publicKey, 'RS256'), you tell the library to only accept RS256-signed tokens verified with that key. This prevents algorithm confusion attacks, where an attacker could send a token signed with HS256 using your public key as the shared secret and your verifier might accept it.

The return type is stdClass

JWT::decode() returns a PHP stdClass object, not an associative array. Access claims with arrow syntax ($decoded->sub), not array syntax ($decoded['sub']). If you prefer arrays:

  
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
$claims = json_decode(json_encode($decoded), true);
// Now you can use $claims['sub'], $claims['email'], etc.
  

Verifying with a JWKS endpoint

This is the recommended approach for production. Your identity provider publishes its public keys at a JWKS URL, and the CachedKeySet class fetches and caches them automatically:

  
<?php

use Firebase\JWT\CachedKeySet;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use Phpfastcache\CacheManager;

$jwksUri = 'https://api.workos.com/sso/jwks/your-client_id';

// PSR-7 HTTP client
$httpClient = new Client();

// PSR-17 HTTP request factory
$httpFactory = new HttpFactory();

// PSR-6 cache (file-based, or use Redis/Memcached in production)
$cacheItemPool = CacheManager::getInstance('files');

$keySet = new CachedKeySet(
    $jwksUri,
    $httpClient,
    $httpFactory,
    $cacheItemPool,
    43200,  // Cache expiry in seconds (12 hours)
    true    // Enable rate limiting (max 10 RPS for key lookups)
);

try {
    $decoded = JWT::decode($jwt, $keySet);
    echo "Subject: " . $decoded->sub . "\n";
    echo "Email: " . $decoded->email . "\n";
} catch (\UnexpectedValueException $e) {
    echo "Verification failed: " . $e->getMessage() . "\n";
}
  

The CachedKeySet class handles several important concerns:

  • Key caching. The JWKS response is fetched once and stored in the PSR-6 cache. Subsequent verifications use the cached keys without making HTTP requests.
  • Automatic key rotation. If a token arrives with a kid that is not in the cache, CachedKeySet re-fetches the JWKS from the endpoint. This handles key rotation transparently.
  • Rate limiting. When the sixth parameter is true, the class limits outbound requests to 10 per second, preventing an attacker from flooding your server with tokens containing unknown kid values and forcing excessive JWKS fetches.

Using CachedKeySet in Laravel

In Laravel, you can use the framework's built-in cache and HTTP client instead of installing Guzzle and phpfastcache separately. Laravel's cache implements the PSR-6 interface through a wrapper:

  
<?php

use Firebase\JWT\CachedKeySet;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\Cache\Adapter\Psr16Adapter;

$jwksUri = config('services.workos.jwks_uri');

$httpClient = new Client();
$httpFactory = new HttpFactory();

// Wrap Laravel's cache (PSR-16) in a PSR-6 adapter
$cacheItemPool = new Psr16Adapter(Cache::store());

$keySet = new CachedKeySet(
    $jwksUri,
    $httpClient,
    $httpFactory,
    $cacheItemPool,
    43200,
    true
);

$decoded = JWT::decode($token, $keySet);
  

This uses whatever cache driver you have configured in Laravel (Redis, Memcached, file, etc.) and shares the cache with the rest of your application.

Adding leeway for clock skew

If your token issuer and your verifier run on different servers, their clocks may be slightly out of sync. The library provides a static property to handle this:

  
// Allow 30 seconds of clock skew
JWT::$leeway = 30;

$decoded = JWT::decode($jwt, $keySet);
  

Set this before calling decode(). Thirty seconds is a reasonable default. Do not set it higher than a few minutes.

Validating issuer and audience

The firebase/php-jwt library validates exp, nbf, and iat automatically but does not validate iss or aud for you. You need to check these yourself after decoding:

  
$decoded = JWT::decode($jwt, $keySet);

if ($decoded->iss !== 'https://your-app.example.com') {
    throw new \UnexpectedValueException('Invalid issuer');
}

if ($decoded->aud !== 'your-api') {
    throw new \UnexpectedValueException('Invalid audience');
}
  

This is a common oversight. The library verifies the cryptographic signature and time-based claims, but application-level claims like iss and aud are your responsibility.

Exception handling

The library throws specific exception types that you can catch individually:

  
use Firebase\JWT\BeforeValidException;  // nbf check failed
use Firebase\JWT\ExpiredException;      // exp check failed
use Firebase\JWT\SignatureInvalidException; // bad signature

try {
    $decoded = JWT::decode($jwt, $keySet);
} catch (ExpiredException $e) {
    // Token expired, return 401
} catch (SignatureInvalidException $e) {
    // Tampered token, return 401
} catch (BeforeValidException $e) {
    // Token not yet valid, return 401
} catch (\UnexpectedValueException $e) {
    // Catch-all for any other JWT error
} catch (\LogicException $e) {
    // Environmental issue (missing OpenSSL, bad key format)
}
  

All JWT-related exceptions extend UnexpectedValueException, so you can catch that as a blanket handler. LogicException covers environmental issues like missing extensions or malformed keys.

Handling custom claims

Once verified, custom claims are available as properties on the returned stdClass object:

  
$decoded = JWT::decode($jwt, $keySet);

$email = $decoded->email;
$roles = $decoded->roles; // array
$department = $decoded->department;
$emailVerified = $decoded->email_verified; // boolean

// Use roles for authorization
if (!in_array('admin', $decoded->roles ?? [])) {
    throw new \Exception('Insufficient permissions');
}
  

For nested claims:

  
if (isset($decoded->feature_flags) && $decoded->feature_flags->beta_access) {
    // Enable beta features
}
  

Remember that JWT::decode returns objects, not arrays. Nested structures like feature_flags are also stdClass objects. Use arrow syntax or cast to arrays if you prefer.

Integrating with Laravel

Laravel is the most popular PHP framework, and JWT authentication in Laravel typically follows one of two patterns: a dedicated middleware or a custom authentication guard.

Middleware approach

For API-only applications, a middleware is the simplest integration:

  
<?php

// app/Http/Middleware/VerifyJwt.php
namespace App\Http\Middleware;

use Closure;
use Firebase\JWT\CachedKeySet;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use Illuminate\Http\Request;
use Symfony\Component\Cache\Adapter\Psr16Adapter;
use Illuminate\Support\Facades\Cache;

class VerifyJwt
{
    public function handle(Request $request, Closure $next)
    {
        $token = $request->bearerToken();

        if (!$token) {
            return response()->json(['error' => 'Missing token'], 401);
        }

        try {
            JWT::$leeway = 30;
            $decoded = JWT::decode($token, $this->getKeySet());

            // Validate issuer and audience
            if ($decoded->iss !== config('services.workos.issuer')) {
                return response()->json(['error' => 'Invalid issuer'], 401);
            }

            if ($decoded->aud !== config('services.workos.audience')) {
                return response()->json(['error' => 'Invalid audience'], 401);
            }

            // Share the claims with the rest of the request
            $request->merge(['jwt_claims' => (array) $decoded]);
            $request->setUserResolver(fn () => $decoded);

        } catch (\UnexpectedValueException $e) {
            return response()->json(['error' => 'Invalid token'], 401);
        }

        return $next($request);
    }

    private function getKeySet(): CachedKeySet
    {
        return new CachedKeySet(
            config('services.workos.jwks_uri'),
            new Client(),
            new HttpFactory(),
            new Psr16Adapter(Cache::store()),
            43200,
            true
        );
    }
}
  

Register the middleware in your application:

  
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'jwt' => \App\Http\Middleware\VerifyJwt::class,
    ]);
})
  

Apply it to routes:

  
// routes/api.php
Route::middleware('jwt')->group(function () {
    Route::get('/dashboard', function (Request $request) {
        $claims = $request->jwt_claims;
        return response()->json([
            'user_id' => $claims['sub'],
            'email' => $claims['email'],
        ]);
    });

    Route::get('/admin', function (Request $request) {
        $claims = $request->jwt_claims;
        if (!in_array('admin', $claims['roles'] ?? [])) {
            return response()->json(['error' => 'Forbidden'], 403);
        }
        return response()->json(['message' => 'Welcome, admin']);
    });
});
  

Service provider approach

For cleaner architecture, register the CachedKeySet as a singleton in a service provider so you do not create a new instance on every request:

  
<?php

// app/Providers/JwtServiceProvider.php
namespace App\Providers;

use Firebase\JWT\CachedKeySet;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
use Symfony\Component\Cache\Adapter\Psr16Adapter;

class JwtServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(CachedKeySet::class, function () {
            return new CachedKeySet(
                config('services.workos.jwks_uri'),
                new Client(),
                new HttpFactory(),
                new Psr16Adapter(Cache::store()),
                43200,
                true
            );
        });
    }
}
  

Then inject it into your middleware:

  
public function __construct(private CachedKeySet $keySet) {}

public function handle(Request $request, Closure $next)
{
    // ...
    $decoded = JWT::decode($token, $this->keySet);
    // ...
}
  

Configuration

Store your JWT configuration in config/services.php:

  
// config/services.php
'workos' => [
    'jwks_uri' => env('WORKOS_JWKS_URI', 'https://api.workos.com/sso/jwks/your-client_id'),
    'issuer' => env('WORKOS_JWT_ISSUER', 'https://your-app.example.com'),
    'audience' => env('WORKOS_JWT_AUDIENCE', 'your-api'),
],
  

And in your .env:

  
WORKOS_JWKS_URI=https://api.workos.com/sso/jwks/your-client_id
WORKOS_JWT_ISSUER=https://your-app.example.com
WORKOS_JWT_AUDIENCE=your-api
  

JWT best practices (PHP edition)

JWTs are simple in structure, but security lives in the details you enforce. Here are the practices that matter most in production Ruby applications.

  • Always verify the signature. Do not trust a token just because it decodes cleanly. Only use claims for authorization decisions after verification succeeds. With the jwt gem, always pass true as the third argument to JWT.decode, or call verify! on JWT::EncodedToken.
  • Enforce the expected algorithm. Always pass algorithm: 'RS256' (or whichever algorithm you expect) in the decode options. "Accept whatever the header says" is how algorithm confusion attacks happen. The jwt gem's README explicitly warns about this.
  • Validate critical standard claims. At minimum, validate exp (expiration), iss (issuer), and aud (audience). Set verify_iss: true, verify_aud: true, and verify_expiration: true in your decode options. If you deal with clock drift between systems, set a small leeway (30 to 60 seconds) rather than loosening validation.
  • Use a JWKS endpoint when possible. If your tokens are issued by an identity provider, verify against their JWKS so you can automatically select the right public key by kid. Use the lambda-based jwks loader for caching and automatic refresh.
  • Plan for key rotation. If you manage your own keys, publish new keys at your JWKS endpoint before you start signing with them, keep old keys available until tokens signed with them expire, and use kid to distinguish active from retired keys. The jwt gem's jwks lambda handles the verifier side of rotation automatically by re-fetching on unknown kid.
  • Enforce Bearer token format. Require tokens in the Authorization header in this exact format: Authorization: Bearer <jwt>. Treat tokens in query parameters as a problem, because they leak into logs, browser history, and referrer headers.
  • Keep access tokens short-lived. Short exp values (5 to 15 minutes) reduce the blast radius of a leaked token. If you need long sessions, use refresh tokens and rotate them.
  • Handle verification errors explicitly. The jwt gem raises specific exception classes: JWT::ExpiredSignature, JWT::InvalidIssuerError, JWT::InvalidAudError, JWT::IncorrectAlgorithm, JWT::VerificationError, and JWT::DecodeError as a catch-all. Map these to clean HTTP responses: 401 for missing, invalid, or expired tokens; 403 for valid tokens that lack required permissions.
  • Use HTTPS everywhere. JWTs are bearer credentials. If someone can intercept the request, they can replay the token.
  • Centralize JWT logic. Put verification in a Rack middleware or a Rails controller concern so every protected endpoint enforces the same checks. Do not scatter partial verification logic across individual actions.
  • Log failures carefully. Log high-level context (like kid, iss, and the reason verification failed) and never log full tokens or entire payloads. In Rails, use Rails.logger with structured tags.
  • Store secrets in Rails credentials. Do not put private keys or JWT secrets in environment variables if you can avoid it. Rails encrypted credentials (rails credentials:edit) are the idiomatic approach for managing secrets in Rails 7 and 8.
  • Test with bad tokens. Make sure your test suite covers expired tokens, tokens with wrong iss or aud, tokens signed with the wrong key, tokens with tampered payloads, missing required claims, and wrong algorithm or none edge cases. The jwt gem makes it easy to generate test tokens with specific properties.

Let WorkOS handle the heavy lifting

While handling JWTs with firebase/php-jwt is often necessary at the API layer, it is worth stepping back and looking at the bigger picture: how those tokens are issued in the first place.

If you are building authentication flows, especially ones that involve Single Sign-On (SSO), SCIM provisioning, or multi-tenant identity, there is a lot more to solve than signing and verifying tokens. You need to support different identity providers, manage users and directories, rotate keys safely, and issue tokens that downstream services can trust.

WorkOS provides a modern API for enterprise-ready authentication features, letting you integrate SSO (SAML, OIDC, and more), manage users and directories, and issue secure tokens without building and maintaining a full auth stack from scratch. WorkOS has both a PHP SDK and a dedicated Laravel SDK that handle the OAuth flow, token exchange, and user management. It is especially useful if you need to support enterprise customers or want to offer a "Login with your company" experience. And it is free for up to 1,000,000 monthly active users.

If you are already running a Laravel app, you may also want to check out the existing guide on building authentication in Laravel, which covers the full authentication story beyond just JWTs.

Sign up for WorkOS today.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.