web-auth/cose-lib
CBOR Object Signing and Encryption (COSE) library for PHP with full support for COSE_Sign1, Sign, Encrypt0, Encrypt, Mac0 and Mac tags, plus ECDSA, EdDSA, RSA, HMAC algorithms. PHP 8.1+ and WebAuthn/FIDO2 compatible.
You probably already have it. web-auth/cose-lib is a transitive dependency of web-auth/webauthn-lib. Check before adding it explicitly:
composer show web-auth/cose-lib
If you are building WebAuthn/FIDO2, EU Digital COVID Certificates, or any CBOR-encoded signature flow, this is the low-level engine underneath. Add it directly only when you need COSE structures outside of WebAuthn.
Install with the CBOR tag layer — it is not optional in practice:
composer require web-auth/cose-lib spomky-labs/cbor-php
cose-lib ships the tag classes (CoseSign1Tag, CoseMac0Tag, …) but the encoder/decoder comes from spomky-labs/cbor-php. Without it you can build COSE objects but not parse or serialize them.
Where to look first
doc/Usage.md in the repo — the README's quick start is a fragment; the usage guide is the real walkthrough.Cose\Algorithm\Manager — the registry that maps a COSE alg integer (−7, −8, −257…) to a PHP algorithm object. Almost every real integration is "get the alg from the header, ask the manager for the handler."Cose\Key\* — Ec2Key, OkpKey, RsaKey, SymmetricKey wrap the CBOR key maps that WebAuthn and DCC put on the wire.Cose\Signature\Signature1 — the SigStructure you must verify over. This trips up nearly everyone; see Gotchas.First use case: verify a COSE_Sign1 blob
use CBOR\Decoder;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\StringStream;
use CBOR\Tag\TagManager;
use Cose\Algorithm\Manager as AlgorithmManager;
use Cose\Algorithm\Signature\ECDSA\ES256;
use Cose\Algorithm\Signature\EdDSA\EdDSA;
use Cose\Key\Ec2Key;
use Cose\Signature\CoseSign1Tag;
use Cose\Signature\Signature1;
$tagManager = TagManager::create()->add(CoseSign1Tag::class);
$decoder = Decoder::create($tagManager, OtherObjectManager::create());
$cose = $decoder->decode(new StringStream($binaryBlob));
$protected = $cose->getProtectedHeader();
$payload = (string) $cose->getPayload();
$signature = (string) $cose->getSignature();
$algId = $protected->get(1)->getValue(); // "alg" header
$algorithms = AlgorithmManager::create()
->add(ES256::create(), EdDSA::create()); // only what you actually accept
$algorithm = $algorithms->get($algId); // throws if unknown
$key = Ec2Key::create($publicKeyCborMap); // from WebAuthn credential
$sigStructure = Signature1::create($protected, '', $payload);
$valid = $algorithm->verify($key, $signature, (string) $sigStructure);
That is the whole shape of the library: decode → pull headers → pick algorithm → verify/sign over SigStructure. Everything else is variations.
Bind the algorithm manager as a singleton. Building the registry is cheap but you do not want it inside a request loop, and you want one place that defines your accept-list:
// app/Providers/CoseServiceProvider.php
public function register(): void
{
$this->app->singleton(AlgorithmManager::class, fn () => AlgorithmManager::create()
->add(
ES256::create(),
ES384::create(),
EdDSA::create(),
RS256::create(),
)
);
$this->app->singleton('cose.decoder', fn () => Decoder::create(
TagManager::create()
->add(CoseSign1Tag::class)
->add(CoseMac0Tag::class),
OtherObjectManager::create()
));
}
Note the accept-list doubles as your security policy: a token signed with an algorithm you never registered fails at get() rather than silently verifying.
Wrap it in a single verification service, not in controllers. This is the pattern that ages well:
final class CoseVerifier
{
public function __construct(
private Decoder $decoder,
private AlgorithmManager $algorithms,
) {}
public function verifySign1(string $blob, string $cborPublicKey): bool
{
$cose = $this->decoder->decode(new StringStream($blob));
if (! $cose instanceof CoseSign1Tag) {
throw new UnexpectedValueException('Not a COSE_Sign1 structure.');
}
$algorithm = $this->algorithms->get($cose->getProtectedHeader()->get(1)->getValue());
return $algorithm->verify(
Ec2Key::create($this->decoder->decode(new StringStream($cborPublicKey))->getValue()),
(string) $cose->getSignature(),
(string) Signature1::create($cose->getProtectedHeader(), '', (string) $cose->getPayload()),
);
}
}
Store credential keys as raw CBOR, not as PEM. When a WebAuthn registration completes you receive a COSE key map. Persist the CBOR bytes in a json/longblob column plus the alg integer. Reconstruct on demand. Converting to PEM at registration time loses the curve/algorithm metadata you need for COSE verification and forces a lossy round-trip. An Eloquent cast that decodes to a Cose\Key\Key subclass works well here.
Signing outbound tokens (IoT, document signing, DCC issuance):
$protected = MapObject::create([
MapItem::create(UnsignedIntegerObject::create(1), NegativeIntegerObject::create(-7)),
]);
$sigStructure = Signature1::create($protected, '', $payload);
$signature = $algorithm->sign($privateEc2Key, (string) $sigStructure);
$cose = CoseSign1Tag::create(
$protected,
MapObject::create([/* kid, etc. */]),
ByteStringObject::create($payload),
ByteStringObject::create($signature),
);
return (string) $cose; // bytes for the wire
Sign the SigStructure, never the payload directly. The library does not enforce this — the type system cannot stop you from passing raw payload bytes to sign() and producing a token nobody can verify.
Batch verification belongs in a queued job. Decoding a DCC or a large attestation set is CPU-bound and can throw on malformed input; put it behind a job with $tries = 1 (malformed CBOR will not fix itself on retry) and log failures. Use ShouldBeUnique per certificate identifier so a replayed import does not fan out.
Integrate via FormRequest + policy, not middleware. A COSE verification is a per-action authorization concern, not a global request concern. Validate the envelope shape in a FormRequest, run verification in a dedicated action or policy, and return a 401/422 with a generic message — the failure reason ("wrong algorithm", "bad signature") is an oracle you don't want to hand out.
Test with fixtures, and assert on the negative path. The repo's own test suite has real COVID certificate fixtures — mirror that approach. Keep a set of known-good blobs and known-tampered ones. For unit tests, test the service with a fake key pair generated in setUp rather than checking in private keys.
You must verify over SigStructure, and the constructor is easy to mis-order. Signature1::create($protectedHeader, $externalAad, $payload) — for WebAuthn and most flows $externalAad is ''. Passing the payload as the second argument produces a signature that verifies nowhere. If your signature check fails against known-good data, print bin2hex((string) $sigStructure) and diff it against a reference implementation first.
ECDSA signatures are raw R||S, not DER. This is the single biggest source of "it works in Python but not PHP." The library's algorithm classes (ES256, etc.) convert internally, so use them. The README's openssl_verify() snippet does not — it silently assumes you have already converted to DER. If you hand-roll verification with OpenSSL, you must convert (fixed-width, high-bit padding) or you get a flat false with no diagnostic.
getProtectedHeader()->get(1) can return null. alg is only required in the protected header by convention; some issuers put it in the unprotected header, and a malicious one may omit it entirely. Read protected first, fall back to unprotected, and reject if absent. Never default to a specific algorithm.
A missing TagManager registration gives you a generic tag, not an error. Decode a COSE_Sign1 without ->add(CoseSign1Tag::class) and you get a plain CBOR\Tag — instanceof CoseSign1Tag is false, and if you don't type-check you'll call methods that don't exist. Always instanceof-guard the decode result.
Negative integers need NegativeIntegerObject. UnsignedIntegerObject::create(-7) throws. COSE algorithm IDs are almost all negative; -7 is ES256, -8 is EdDSA, -257 is RS256. Encode them with NegativeIntegerObject::create(-7).
RSA keys are not validated for you. RsaKeyValidator::create()->check($key) enforces RFC 8812's 2048-bit minimum plus RFC 8017 exponent constraints. If you accept RSA keys from an untrusted registration, run this deliberately — the library will happily sign with a 512-bit modulus otherwise.
RS1 emits E_USER_WARNING on creation. If your error handler converts warnings to exceptions (Laravel's default in many setups with ErrorException handling), a legacy authenticator will crash your request with a confusing message. Construct it as RS1::create(acknowledgeInsecureAlgorithm: true) and, better, log which credentials force you into it so you can drop them.
Ed448 needs PHP 8.4. It routes through OpenSSL, not sodium, and PHP only wires up Edwards curves from 8.4. Guard with Ed448::isSupported() rather than catching an exception at verify time.
Detached payloads: getPayload() returns null. Some COSE structures carry the payload out-of-band (large documents, streaming). The signature is still over the SigStructure with the payload included, so you must supply the detached bytes yourself. Do not treat null as "empty payload."
Debug with hex, not with dd(). CBOR objects do not var_dump usefully. Use bin2hex($blob) and paste into cbor.me, and compare against bin2hex((string) $sigStructure). Nine times out of ten the hex tells you the story instantly.
Extension points, if you need them: register custom algorithm implementations in the AlgorithmManager (any class implementing the signature/MAC interfaces), and register custom tag classes on the TagManager for proprietary COSE tags. Both are open registries — no interfaces to subclass beyond the algorithm contracts.
Pin the version and read the release notes. The library is actively maintained (RFC 9864 fully-specified algorithms landed in 4.7, key validation in the same minor) but has a small install base, so regressions surface later than in a high-traffic package. The FullySpecified namespace (ESP256, Ed25519 at −19) and the classic namespace (ES256, EdDSA at −8) compute identical signatures with different identifiers — know which one your counterparty expects before you swap.
How can I help you explore Laravel packages today?