daycry/jwt

JWT Token for Codeigniter 4

Maintainers

Package info

github.com/daycry/jwt

pkg:composer/daycry/jwt

Statistics

Installs: 6 815

Dependents: 4

Suggesters: 0

Stars: 1

Open Issues: 0


README

A JWT (JSON Web Token) library for CodeIgniter 4, built on top of lcobucci/jwt ^5. Supports HMAC, RSA and ECDSA, an immutable faΓ§ade, and key rotation.

πŸ“– Full documentation: https://daycry.github.io/jwt/ β€” this README is a quick overview; the site has the complete, searchable reference.

Package

Latest Stable Version Total Downloads Monthly Downloads PHP Version Require License

Quality

PHP Tests PHPStan Psalm Rector Code Style CodeQL Docs Coverage Status

Community

GitHub stars Donate

Requirements

  • PHP 8.2 or higher
  • CodeIgniter 4.x
  • lcobucci/jwt ^5.5

Upgrading from v2.x? Read the v2 β†’ v3 migration guide.

Installation

composer require daycry/jwt

Publish the configuration file

php spark jwt:publish

Generate a signing key

php spark jwt:key

The key is written automatically to .env as jwt.signer. Use --show to print it without touching the file.

⚠️ Never commit .env to version control.

Quick Start

php spark jwt:publish     # write app/Config/JWT.php
php spark jwt:key         # generate jwt.signer in .env
use Daycry\JWT\JWT;

$jwt = JWT::for();                 // pulls config('JWT')
// or inject an explicit config: new JWT(config('JWT'));

// Encode β€” the uid may be a string or an integer ID (e.g. a DB primary key)
$token = $jwt->encode(['user_id' => 42, 'role' => 'admin'], 'user-42');

// Decode + validate (throws on failure)
$claims = $jwt->decode($token);                  // Plain
echo $claims->claims()->get('uid');              // "user-42"

// Symmetric helper β€” get the original payload back
$payload = $jwt->getPayload($token);             // ['user_id' => 42, 'role' => 'admin']

// Non-throwing alternative
$claims = $jwt->tryDecode($maybeBadToken);
if ($claims === null) {
    return $this->response->setStatusCode(401);
}

The library throws JWTConfigurationException if jwt.signer, jwt.issuer, jwt.audience, or jwt.identifier is missing β€” both null and an empty string "" are rejected. Defaults are intentionally null to fail loudly.

Configuration

After publishing, edit app/Config/JWT.php. All properties are inherited from Daycry\JWT\Config\JWT and overridable via .env.

HMAC (default)

jwt.algorithmType = "symmetric"
jwt.signer        = "<base64-secret-from-jwt:key>"
jwt.issuer        = "https://api.my-app.com"
jwt.audience      = "https://my-app.com"
jwt.identifier    = "my-app-v2"
jwt.expiresAt     = "+1 hour"
jwt.leeway        = "30"

RSA / ECDSA

php spark jwt:keypair --algorithm=rsa --bits=2048 --output=writable/keys
jwt.algorithmType = "asymmetric"
jwt.signingKey    = "/var/www/app/writable/keys/jwt-private.pem"
jwt.verifyingKey  = "/var/www/app/writable/keys/jwt-public.pem"
jwt.issuer        = "https://api.my-app.com"
jwt.audience      = "https://my-app.com"
jwt.identifier    = "my-app-v2"

In app/Config/JWT.php set the signer class:

public string $algorithm = \Lcobucci\JWT\Signer\Rsa\Sha256::class;   // RS256
// or \Lcobucci\JWT\Signer\Ecdsa\Sha256::class for ES256

See docs/configuration.md for the full reference.

Usage

Compact array payload (default)

$token = $jwt->encode(['user_id' => 1, 'role' => 'admin']);

$payload = $jwt->getPayload($token);  // ['user_id' => 1, 'role' => 'admin']

Split mode β€” claims at the top level

$jwt   = JWT::for()->withSplitData();
$token = $jwt->encode(['user_id' => 1, 'role' => 'admin']);
$claims = $jwt->decode($token);

echo $claims->claims()->get('role');  // "admin"

Custom payload claim name

$jwt = JWT::for()->withParamData('payload');
$jwt->getPayload($jwt->encode('hello'));  // "hello"

Short-lived tokens (withExpiresAt)

Override the configured expiresAt modifier for a single instance, without mutating the shared config β€” useful for short-lived access tokens. Like every with*() method it returns a new instance. Passing an empty string throws InvalidArgumentException.

$accessToken = JWT::for()->withExpiresAt('+5 minutes')->encode($data);

Clock skew tolerance (LooseValidAt)

$jwt = JWT::for()->withLeeway(30);   // accept up to Β±30s of skew
$jwt = JWT::for()->withLeeway(null); // reset to no leeway

withLeeway() accepts null to reset to "no leeway"; a negative value throws InvalidArgumentException.

Per-instance customisers (3.2.0)

Every customiser returns a new instance, leaving the shared config untouched. Override claims, audiences, headers and extra claims per call:

$jwt = JWT::for()
    ->withIssuer('https://api.my-app.com')
    ->withAudience('https://app-a.com', 'https://app-b.com') // multiple audiences
    ->withIdentifier(bin2hex(random_bytes(16)))              // unique jti
    ->withClaims(['scope' => 'admin'])                        // extra top-level claims
    ->withHeader('x-trace', $traceId);                        // custom JOSE header

$claims = $jwt->getClaims($token);            // validated array of all claims
$scope  = $jwt->getClaim($token, 'scope');    // validated single claim

Key rotation with kid (3.2.0)

Tag issued tokens with a kid header and verify against a per-kid key map, so you can roll keys without invalidating tokens still in flight:

// Issuing side β€” stamp the active key id.
$token = JWT::for()->withKeyId('2026-06')->encode($data);

// Verifying side (app/Config/JWT.php) β€” accept old and new keys during the window.
public ?string $keyId        = '2026-06';
public array   $verifyingKeys = [
    '2026-05' => '/path/old-public.pem',
    '2026-06' => '/path/new-public.pem',
];

On decode, the token's kid selects the matching key from $verifyingKeys (falling back to $verifyingKey / $signer). The configured signer/algorithm is always used, so a token's kid can never downgrade the verifier.

Error Handling

use Daycry\JWT\Exceptions\InvalidTokenException;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;

try {
    $claims = $jwt->decode($token);
} catch (RequiredConstraintsViolated $e) {
    // Signature, issuer, audience, exp, etc.
    return $this->response->setStatusCode(401)->setJSON(['error' => $e->getMessage()]);
} catch (InvalidTokenException $e) {
    // Malformed or non-Plain token.
    return $this->response->setStatusCode(400)->setJSON(['error' => 'Bad token']);
}

For a non-throwing flow:

$claims = $jwt->tryDecode($token);
if ($claims === null) {
    return $this->response->setStatusCode(401);
}

Fail-closed configuration guards

The library refuses unsafe configurations up front instead of silently producing weak tokens. JWTConfigurationException is thrown when:

  • jwt.validateClaims does not contain 'SignedWith' while jwt.validate = true β€” decode() will not skip signature verification. To decode without any validation, set jwt.validate = false (intended for tests/debug only; decode() then logs a warning).
  • jwt.algorithmType and jwt.algorithm disagree β€” 'symmetric' requires an Lcobucci\JWT\Signer\Hmac\* signer, 'asymmetric' requires Rsa\* or Ecdsa\*. (E.g. leaving the default HMAC Sha256 on an 'asymmetric' type is caught with a clear message instead of a cryptic key error.)

An invalid jwt.canOnlyBeUsedAfter or jwt.expiresAt modifier (anything DateTimeImmutable::modify() rejects) throws InvalidArgumentException consistently across PHP versions.

Utility Methods

Method Returns Description
decode(string $token) Plain Validates and returns the parsed token. Throws on failure.
tryDecode(string $token) ?Plain Like decode() but returns null on a token failure. A JWTConfigurationException (misconfiguration) still propagates.
getPayload(string $token) mixed Validates + returns the original payload (auto-decoded for compact mode).
getClaims(string $token) array Validated array of all claims (the safe counterpart of extractClaimsUnsafe()).
getClaim(string $token, string $name) mixed Validated single claim value (null when absent).
isValid(string $token) bool True iff tryDecode() succeeds.
isExpired(string $token) bool True for malformed/expired tokens. Parses without verifying the signature β€” never gate access on it.
getTimeToExpiry(string $token) ?int Seconds until exp, or null. Does not verify the signature.
extractClaimsUnsafe(string $token) ?array Claims without validation. Logs a warning unless Config::$allowUnsafeExtraction = true.

CLI Commands

# Publish config to app/Config/JWT.php
php spark jwt:publish

# Generate an HMAC key (default 32 bytes) and write to .env
php spark jwt:key
php spark jwt:key 64 --show
php spark jwt:key --force

# Generate an asymmetric key pair
php spark jwt:keypair --algorithm=rsa   --bits=2048
php spark jwt:keypair --algorithm=ecdsa --curve=prime256v1 --output=writable/keys

On Windows, jwt:keypair warns that chmod() cannot enforce file permissions β€” restrict the private key with NTFS ACLs (e.g. icacls) instead. It also warns when --passphrase is passed on the command line, because that value can leak via the process list and shell history; prefer a secrets manager or interactive entry.

Security Best Practices

  1. Use a strong key β€” php spark jwt:key enforces a 32-byte (256-bit) minimum, the floor for HS256.
  2. Set short expiry times for API access tokens (withExpiresAt('+15 minutes')).
  3. Enable all validation constraints in production (keep 'SignedWith' in jwt.validateClaims).
  4. Never commit .env or any file containing jwt.signer / private keys.
  5. Rotate keys without downtime using the kid header and jwt.verifyingKeys map (see Key rotation) β€” keep the old key in the map until its tokens have expired, then drop it. If a key is leaked, remove it from the map immediately to revoke its tokens.

Testing

composer test
# or without coverage (faster)
vendor/bin/phpunit --no-coverage

Documentation

πŸ“– The full, searchable documentation is published at https://daycry.github.io/jwt/ (built with MkDocs Material from the docs/ folder).

Document Description
Getting Started Installation and first token in minutes
Configuration Every property, its type, default, and .env key
Usage Complete API reference with examples
Advanced Utility methods, key rotation, middleware, multi-tenant patterns
CLI Commands jwt:key, jwt:keypair, jwt:publish reference
Threat Model Security model and guarantees
Testing Test suite structure and writing new tests
Migration v2 β†’ v3 Upgrade guide

Contributing

  1. Fork the repository
  2. Create a feature branch: git checkout -b feature/my-feature
  3. Commit your changes: git commit -m 'Add my feature'
  4. Push and open a Pull Request

License

MIT β€” see LICENSE.

Support