NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Jose\Jws signs and verifies compact JWS (header.payload.signature). Verification is deliberately strict: it enforces structure, an 8 KB size cap, a pre-signature algorithm pin and crit rejection — nothing else. Temporal checks are opt-in:

use RoundlyConsulting\Crypto\Facades\Crypto;
use RoundlyConsulting\Crypto\Signature\Algorithm;

// Sign with the private half, verify with the public half
$private = Crypto::keys()->ec()->fromStorageOrGenerate('local', 'keys/ec.pem');   // P-256
$public  = Crypto::keys()->ec()->public($private->publicPem());

$token  = Crypto::jws()->sign(['kid' => 'k1'], ['sub' => 'alice', 'exp' => now()->addHour()->timestamp], Crypto::es($private));
$claims = Crypto::jws()->verify($token, Crypto::es($public), Algorithm::ES256);
$claims->assertTemporal(leeway: 30);   // opt-in: throws if expired / not yet valid
$sub = $claims->string('sub');

// Shared-secret tier: an HS256 signer from YOUR config key
$hs = Crypto::hs(Crypto::keys()->hmac()->fromConfig('tokens.secret'));

Or call the classes the facade fronts directly:

use RoundlyConsulting\Crypto\Jose\Jws;
use RoundlyConsulting\Crypto\Signature\Algorithm;
use RoundlyConsulting\Crypto\Signature\Hs;
use RoundlyConsulting\Crypto\Signature\Key\HmacSecret;

$jws = new Jws;
$signer = new Hs(HmacSecret::fromString($secret)); // ≥32 random bytes

$token = $jws->sign(['kid' => 'k1'], ['sub' => 'alice', 'exp' => now()->addHour()->timestamp], $signer);

$claims = $jws->verify($token, $signer, Algorithm::HS256);
$claims->assertTemporal(leeway: 30);   // opt-in: throws if expired / not yet valid
$sub = $claims->string('sub');

What verify() checks, in order

  • The verifier’s algorithm must equal the expected one (AlgorithmMismatchException).
  • Tokens over Jws::MAX_ENCODED_BYTES (8192) are rejected (MalformedTokenException).
  • The token must have exactly three segments, and a crit header is rejected.
  • The header alg must string-equal the expected algorithm before the signature is touched — blocking alg:none downgrades and RS256↔HS256 confusion.
  • Finally the signature is verified (InvalidSignatureException on failure).

The key and the algorithm are never selected from the token header. sign() always writes typ: JWT and the signer’s alg into the header — you cannot override alg — and encodes JSON deterministically, so byte-for-byte fixtures reproduce exactly. The claims are always a JSON object (RFC 7519 §7.2): no claims encode as {}, never the [] PHP makes of an empty array, and verify() refuses a header or payload that is not a JSON object (MalformedTokenException).

RSA, ECDSA and EdDSA

The other tiers work the same way with Rs/Es and RsaKey/EcKey. RSA takes the tier as an argument; ECDSA reads it from the key’s curve:

use RoundlyConsulting\Crypto\Signature\Rs;
use RoundlyConsulting\Crypto\Signature\Es;
use RoundlyConsulting\Crypto\Signature\Key\RsaKey;
use RoundlyConsulting\Crypto\Signature\Key\EcKey;

$token  = $jws->sign([], ['sub' => 'bob', 'exp' => $exp], new Rs(RsaKey::private($privatePem), Algorithm::RS512));
$claims = $jws->verify($token, new Rs(RsaKey::public($publicPem), Algorithm::RS512), Algorithm::RS512);

// ES512 on a P-521 key — the curve fixes the digest:
$es = $jws->sign([], ['sub' => 'kim'], new Es(EcKey::private($p521Pem)));
$jws->verify($es, new Es(EcKey::public($p521PublicPem)), Algorithm::ES512);

A signer built from a private key verifies too — it checks against the derived public half — so a service that issues and checks its own tokens can pass the same Rs or Es instance to both calls.

EdDSA (Ed25519) signs and verifies when ext-sodium is present:

use RoundlyConsulting\Crypto\Signature\EdDSA;
use RoundlyConsulting\Crypto\Signature\Key\OkpKey;

$key = OkpKey::generate();                          // or OkpKey::fromSecretKey($sk)
$token = $jws->sign([], ['sub' => 'ed'], new EdDSA($key));
$jws->verify($token, new EdDSA(OkpKey::ed25519($key->publicKey)), Algorithm::EdDSA);

Flattened JWS (ACME)

flattened() produces an RFC 7515 §7.2.2 flattened JWS, as ACME uses. The alg is merged into the protected header, and an empty payload encodes to an empty segment (ACME POST-as-GET). The returned FlattenedJws is JsonSerializable:

use RoundlyConsulting\Crypto\Signature\Key\RsaKey;
use RoundlyConsulting\Crypto\Signature\Rs;

$flattened = $jws->flattened(
    protected: ['nonce' => $nonce, 'url' => $url],
    payload:   $payloadJson,                          // '' encodes to an empty segment (POST-as-GET)
    signer:    new Rs(RsaKey::private($pem)),
);

$wire = json_encode($flattened);   // {"protected":"…","payload":"…","signature":"…"}

Claims

A successful verify() returns an immutable Claims bag with typed, validating accessors. Claim policy — issuer, audience, scope — stays with you:

$claims->has('scope');          // bool
$claims->get('aud');            // mixed, null when absent
$claims->require('iss');        // mixed — throws ClaimMismatchException when absent

$sub    = $claims->string('sub');
$scopes = $claims->list('scope');   // list<string>
$issued = $claims->int('iat');
$all    = $claims->all();
MethodReturnsThrows
has($name)bool—
get($name)mixedNever — returns null when absent.
require($name)mixedClaimMismatchException when absent.
string($name)stringClaimMismatchException when absent or not a string.
int($name)intClaimMismatchException when absent, not an integer (1.0 is accepted, 1.5 is not), or a whole number outside the 64-bit integer range.
list($name)list<string>ClaimMismatchException when absent or not a list of strings.
all()array—
assertTemporal($leeway = 0)voidTokenExpiredException or TokenNotYetValidException.

Opt-in temporal validation

assertTemporal() follows RFC 7519: exp is required and must be in the future; nbf and iat, when present, must not be in the future. The leeway (seconds) absorbs clock skew between issuer and verifier, and the current time is read via CarbonImmutable::now(), so tests can pin it with setTestNow():

use RoundlyConsulting\Crypto\Jose\TokenExpiredException;
use RoundlyConsulting\Crypto\Jose\TokenNotYetValidException;

try {
    $claims->assertTemporal(leeway: 30);   // seconds of clock-skew tolerance
} catch (TokenExpiredException $e) {
    // exp has passed (exp is required)
} catch (TokenNotYetValidException $e) {
    // nbf or iat is in the future
}

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.