Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Jws Ecdsa Laravel Package

jdr/jws-ecdsa

A PHP package for signing and verifying JSON Web Signatures (JWS) with ECDSA. It provides a straightforward API for generating compact JWS tokens and validating signatures in Laravel and other PHP applications, useful for secure token exchange and standards-based JWT/JWS workflows.

View on GitHub
Deep Wiki
Context7

Getting Started

This package is a thin ECDSA signer adapter for lcobucci/jwt. Before you install it, find out whether you actually need it — that five-minute check saves you a week of supply-chain anxiety.

Step 0: Check if you already have ECDSA support

composer show lcobucci/jwt
ls vendor/lcobucci/jwt/src/Signer/Ecdsa/

If Lcobucci\JWT\Signer\Ecdsa\Sha256 (and 384/512) exists in your vendored copy, do not adopt this package. You already have the capability and adding a single-maintainer dependency for zero net functionality is a net negative. Close the ticket.

You should only install jdr/jws-ecdsa when:

  • You're pinned to lcobucci/jwt v3.x (no native ECDSA signers), and
  • You have a concrete requirement for ES256/384/512 (Sign in with Apple, an OIDC IdP with EC JWKS, mobile token size, or a counterparty that mandates ECDSA).

Step 1: Confirm OpenSSL has EC curves

php -r "var_dump(in_array('prime256v1', openssl_get_curve_names()));"

prime256v1, secp384r1, secp521r1 are the three curves you care about for ES256/384/512. Alpine images and some distroless setups ship OpenSSL without these — verify in your actual container image, not your host.

Step 2: Read the source, not just the README

At 1 star and 0 dependents, the README is a suggestion. Read every PHP file in vendor/jdr/jws-ecdsa/src/ — there will be one to three classes. Note the namespace (likely Jdr\JwsEcdsa\...) and whether signers are constructed with a curve/hash or a raw algorithm name.

Step 3: Generate a keypair outside the repo

mkdir -p storage/keys
openssl ecparam -name prime256v1 -genkey -noout -out storage/keys/jwt-private.pem
openssl ec -in storage/keys/jwt-private.pem -pubout -out storage/keys/jwt-public.pem
chmod 600 storage/keys/jwt-private.pem

Add storage/keys/ to .gitignore. In production, mount these from secrets (AWS Secrets Manager, Vault, etc.).

Step 4: First sign-and-verify round trip

use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Ecdsa\Sha256; // swap for jdr class if on v3

$config = Configuration::forAsymmetricSigner(
    new Sha256(), // or the jdr equivalent
    InMemory::file(storage_path('keys/jwt-private.pem')),
    InMemory::file(storage_path('keys/jwt-public.pem')),
);

$token = $config->builder()
    ->issuedBy(config('app.url'))
    ->permittedFor('api')
    ->issuedAt(new \DateTimeImmutable())
    ->expiresAt((new \DateTimeImmutable())->modify('+15 minutes'))
    ->getToken($config->signer(), $config->signingKey());

$jws = $token->toString(); // compact JWS

Verify immediately with $config->parser()->parse($jws) and $config->validator()->assert(...). If that round-trips locally, you're at basecamp — the real work (external verifier compatibility) is ahead.

First real use case for most Laravel teams: issuing short-lived tokens to a mobile client or verifying Sign in with Apple identity tokens, not authenticating your own users. The latter is what tymon/jwt-auth already does over HS256.

Implementation Patterns

Bind the signer as a singleton in a service provider. Parsing the PEM key and instantiating the signer on every request is wasteful and makes rotation a headache.

// app/Providers/JwtServiceProvider.php
public function register(): void
{
    $this->app->singleton(Configuration::class, function () {
        return Configuration::forAsymmetricSigner(
            new Sha256(),
            InMemory::file(config('jwt.private_key_path')),
            InMemory::file(config('jwt.public_key_path')),
        );
    });
}

Wrap it behind your own interface. This is the single most important pattern. Do not let Lcobucci\JWT\Signer or Jdr\JwsEcdsa types bleed into controllers, jobs, or middleware. When lcobucci v5 lands and this package is abandoned, you swap one class.

interface TokenIssuer
{
    public function issue(array $claims, \DateTimeImmutable $expires): string;
    public function verify(string $jws): array;
}

final class EcJwtIssuer implements TokenIssuer
{
    public function __construct(private readonly Configuration $config) {}

    public function issue(array $claims, \DateTimeImmutable $expires): string
    {
        $builder = $this->config->builder()->expiresAt($expires);
        foreach ($claims as $key => $value) {
            $builder = $builder->withClaim($key, $value);
        }
        return $builder->getToken(
            $this->config->signer(),
            $this->config->signingKey()
        )->toString();
    }

    public function verify(string $jws): array
    {
        $token = $this->config->parser()->parse($jws);
        if (! $this->config->validator()->validate(
            $token,
            ...$this->config->validationConstraints()
        )) {
            throw new InvalidToken();
        }
        return $token->claims()->all();
    }
}

Middleware pattern for verifying inbound tokens. When verifying tokens from a third-party IdP (Apple, an OIDC provider publishing EC JWKS), you're using the lcobucci parser with their public key, not yours. The jdr package only helps with the signing side; verification is standard lcobucci.

final class VerifyEcJwt
{
    public function handle($request, Closure $next)
    {
        try {
            $claims = app(TokenIssuer::class)->verify($request->bearerToken());
        } catch (\Throwable $e) {
            return response()->json(['error' => 'invalid_token'], 401);
        }
        $request->attributes->set('claims', $claims);
        return $next($request);
    }
}

Algorithm allow-listing. Never let the incoming token's alg header dictate which verifier you pick — that's the classic alg=none / algorithm-confusion attack surface. On the verification side, hard-pin the algorithm you expect and reject anything else.

$unsigned = $this->config->parser()->parse($jws);
if ($unsigned->headers()->get('alg') !== 'ES256') {
    throw new InvalidToken('unexpected alg');
}

Key rotation pattern with kid. Even with a single active key, put a kid header on tokens you issue. When you rotate, verification code needs to pick the right key by kid — building that logic in from day one is far cheaper than retrofitting it.

$builder = $builder->withHeader('kid', config('jwt.active_kid'));

Your verifier then looks up the public key by kid from a small map. When you're ready to rotate, publish the new public key but keep the old one in the trust store for a full token-lifetime window.

Testing pattern — use a fixture key, never production keys. Check in a dedicated test keypair under tests/fixtures/ and wire the test config to it. Never reference storage/keys from PHPUnit; it will eventually leak into CI logs.

// phpunit.xml.dist
<env name="JWT_PRIVATE_KEY_PATH" value="tests/fixtures/jwt-private.pem"/>
<env name="JWT_PUBLIC_KEY_PATH" value="tests/fixtures/jwt-public.pem"/>

Round-trip test against an external verifier is non-negotiable. Self-verification passing is necessary but not sufficient — the encoding pitfalls (DER vs raw R‖S) only show up when a strict external verifier parses your token. Add at least one test that hits a real external verifier (a Go service with golang-jwt, a Node service with jose, or jwt.io in a manual smoke test).

/** @group external */
public function test_token_verifies_against_jose_library(): void
{
    $jws = app(TokenIssuer::class)->issue(['sub' => '123'], now()->addHour());
    $response = Http::post('http://verifier.test/verify', ['token' => $jws]);
    $response->assertOk();
}

Dual-verify during migration. If you're swapping from RS256 to ES256 (or from a custom signer to this one), verify both old and new during rollout, then flip issuance. Keep the old verifier alive until all old tokens have expired.

public function verify(string $jws): array
{
    foreach ($this->verifiers as $verifier) {
        try {
            return $verifier->verify($jws);
        } catch (InvalidToken) {
            continue;
        }
    }
    throw new InvalidToken();
}

Gotchas and Tips

The package is dead weight if you're on lcobucci v4+. This is the gotcha. The most common way to have a bad time with jdr/jws-ecdsa is to install it, configure it, ship it, and six months later discover the native classes already did the job. Before you write a single line of integration code, run ls vendor/lcobucci/jwt/src/Signer/Ecdsa/ and confirm it's empty. If it isn't empty, stop.

You are adopting source code, not a dependency. With 1 star, 0 dependents, and a single maintainer, treat this as you would a GitHub gist you found and liked. Vendor or fork it. Pin the commit. Enable Dependabot/Renovate so at least you notice if the upstream repo disappears rather than silently relying on Packagist.

composer require jdr/jws-ecdsa:dev-main#abc1234

Curve/hash pairing must be exact. ES256 is P-256 + SHA-256, ES384 is P-384 + SHA-384, ES512 is P-521 + SHA-512. Mismatching them (usually by generating a P-256 key and trying to sign ES384) produces tokens that verify locally with relaxed code but fail against strict external verifiers. Add an environment-startup assertion that signs and verifies a sample token with the configured algorithm.

The DER-vs-raw encoding trap. OpenSSL emits ECDSA signatures in DER format (SEQUENCE { r, s }). JWS mandates a fixed-width R‖S concatenation. If the signer implementation gets this wrong, or gets it wrong only for edge cases (leading zeros, very large R/S), local round-trips pass because the same code both signs and verifies, but every external verifier rejects the token. The only way to catch this reliably is external verification, covered above.

Config caching bites on key path changes. php artisan config:cache freezes config('jwt.private_key_path') at build time. If you're using storage_path() with different environments, this resolves to a single path when the config is cached. Use absolute paths from an env var, not storage_path() with a relative suffix.

Never config('jwt.private_key') as a string. PEMs contain newlines and \n sequences that break inside .env files. Always use a file path and InMemory::file(). If you must inline a PEM (e.g., in a secrets-manager payload), call InMemory::plainText() with the string after str_replace('\n', "\n", $pem) — and then go fix it properly.

The tymon/jwt-auth compatibility gap. tymon/jwt-auth doesn't natively speak lcobucci v4 signer classes in older versions, and its ECDSA support is spotty. If you're using this package via tymon, you need to override the JWT::getSigner() binding or swap the Signer resolved from the container. Test this in a scratch branch first — the tymon internals have changed significantly across 1.x-2.x.

Runtime without EC support fails only in some pods. If you deploy to a Kubernetes cluster where the base image was built with a minimal OpenSSL, some replicas will throw openssl_sign(): supplied key param cannot be coerced into a private key (or similar) while others succeed. Catch this at startup with a health check, not at first token issuance under load.

Key loading is slow — cache the parsed key. InMemory::file() reads from disk every call. If you're not binding as a singleton, you'll pay this on every request. The container binding above is the fix; also verify it survives Octane workers (it does, since singletons are constructed per-worker).

Clock skew is real. The lcobucci validator allows zero leeway by default. In distributed systems (or with Apple/Google IDPs whose iat/exp are set on their clocks), you need Lcobucci\Clock\SystemClock with a leeway-adjusted validator:

$validator = $config->validator();
$validator->setLeeway(30); // seconds

Without this, you'll see a low-rate, hard-to-reproduce stream of "token expired" errors that look like a bug in your issuer.

Don't test with production keys. It sounds obvious. It isn't. The most common incident here is a test job accidentally signing tokens with a production key that was mounted for integration testing. Generate a dedicated test keypair, keep it under tests/fixtures/, and add a pre-commit check that no *.pem outside tests/fixtures/ is tracked.

Extension point worth knowing: if you later need additional algorithms (EdDSA, PS256), the lcobucci Signer interface is the place to plug in — and if you already wrapped everything behind TokenIssuer, that's a one-class change. If you didn't wrap it, it's a codebase-wide refactor.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console