Skip to content
ecdsa.com
PHP

PHP openssl extension

PHP's bundled openssl extension: DER ECDSA signatures over the curves of the linked OpenSSL, Ed25519 and Ed448 key types, and an openssl_verify() that returns -1 — a truthy value — for malformed input.
License
PHP-3.01
Checked version
8.5.11 ·
Source
github.com/php/php-src
Documentation
www.php.net/manual/en/book.openssl.php

Compatibility at a glance

ECDSAEdDSA

ECDSA curves
P-224P-256P-384P-521secp256k1brainpoolP256r1brainpoolP384r1brainpoolP512r1
EdDSA
Ed25519Ed448
Public key formats
spki-pemx509
Private key formats
pkcs8-pemencrypted-pkcs8sec1-pem

openssl_sign(), openssl_verify() and the openssl_pkey_* functions are thin wrappers around the OpenSSL that PHP was built against — 3.5.7 in the official php:8.5.11 image — so curves and behaviour follow it: DER signatures, random nonces, no low-S normalization, and verification that accepts high-S. The hash always comes from the algorithm argument; there is no function that signs a precomputed digest.

The return value of openssl_verify() is the classic PHP pitfall. It returns 1 for a valid signature, 0 for an invalid one and -1 when the signature cannot even be parsed — which is what a 64-byte raw r‖s signature from WebCrypto or a JWT produced in the check. In PHP -1 is truthy, so if (openssl_verify(...)) accepts garbage; compare with === 1 as the sample does. Raw signatures have to be wrapped in DER first, which is what every PHP JWT library does internally; the DER ⇄ raw converter shows the two forms.

Keys are passed as PEM strings, certificates or key resources: SPKI PEM and X.509 PEM worked, while raw SPKI DER bytes were rejected outright. Private keys load from PKCS#8 (plain or encrypted) and from SEC1 EC PRIVATE KEY, and openssl_pkey_export() writes PKCS#8. Curves covered P-224 to P-521, secp256k1 and Brainpool, and Ed25519 and Ed448 keys work through OPENSSL_KEYTYPE_ED25519 and OPENSSL_KEYTYPE_ED448 with algorithm 0.

Post-quantum algorithms are not reachable from PHP code even though the linked OpenSSL implements them: the CLI in the same image listed ML-DSA, but the extension defines no key type for it. For JWTs use firebase/php-jwt, web-token or lcobucci/jwt rather than assembling tokens by hand.

Facts and the checks behind them

Nothing below is copied from documentation. The check content/libraries/verify/php-openssl/run.sh ran PHP openssl extension 8.5.11 on OpenSSL 3.5.7 9 Jun 2026 in the container image php:8.5.11-cli-trixie on October 20, 2026; the page must match what it printed, and each fact lists those lines.

  • ECDSA signature encoding

    der

    openssl_sign() returns ASN.1 DER.

    Recorded by the check
    fact ecdsa-signature-encoding = der
  • ECDSA nonce

    random

    Random nonce: two signatures of the same message differ.

    Recorded by the check
    fact ecdsa-nonce = random
  • Low-S when signing

    either

    s is not normalized: high-S values appeared among 64 signatures.

    Recorded by the check
    fact ecdsa-low-s-sign = either
  • High-S when verifying

    accepts-high-s

    openssl_verify() returned 1 for the signature with s replaced by n − s.

    Recorded by the check
    fact ecdsa-low-s-verify = accepts-high-s
  • What the signing call hashes

    hashes-message

    The data is always hashed with the algorithm argument; no digest-signing function exists.

    Recorded by the check
    fact ecdsa-prehash = hashes-message
    evidence ecdsa-prehash: openssl_sign() always hashes with the algorithm given; there is no digest-signing function
  • Malformed ECDSA signature

    returns-error

    A raw r‖s signature and a 3-byte blob make openssl_verify() return -1; a valid signature over another message returns 0.

    Recorded by the check
    fact ecdsa-malformed-signature = returns-error
    evidence ecdsa-malformed-signature: raw r||s: openssl_verify returned -1
    evidence ecdsa-malformed-signature: 3 bytes: openssl_verify returned -1
    evidence ecdsa-malformed-signature: wrong message: openssl_verify returned 0
  • Return values of verification

    -10

    Besides 1 for valid, the check saw -1 (unparsable) and 0 (wrong signature); (bool) -1 is true in PHP.

    Recorded by the check
    fact verify-return-values = -1, 0
    evidence verify-return-values: -1 is truthy in PHP: (bool) -1 === true
  • ECDSA curves

    P-224P-256P-384P-521secp256k1brainpoolP256r1brainpoolP384r1brainpoolP512r1

    P-224, P-256, P-384, P-521, secp256k1 and brainpoolP256r1/P384r1/P512r1 signed and verified.

    Recorded by the check
    fact ecdsa-curves = P-224, P-256, P-384, P-521, secp256k1, brainpoolP256r1, brainpoolP384r1, brainpoolP512r1
  • EdDSA

    Ed25519Ed448

    Ed25519 and Ed448 through OPENSSL_KEYTYPE_ED25519 and OPENSSL_KEYTYPE_ED448; libsodium's sodium_crypto_sign_* is also bundled.

    Recorded by the check
    fact eddsa = Ed25519, Ed448
    evidence eddsa: OPENSSL_KEYTYPE_ED25519 defined: true; sodium_crypto_sign available: true
  • Post-quantum algorithms

    none

    No post-quantum key type constants in the extension, although the image's OpenSSL CLI lists ML-DSA.

    Recorded by the check
    fact pqc = none
    evidence pqc: the openssl CLI of the same image lists 3 ML-DSA signature algorithm lines; the extension has no ML-DSA key type constant (openssl_pkey_new with an unknown type: false)
  • Public key formats

    spki-pemx509

    SPKI PEM and X.509 certificates in PEM; raw SPKI DER bytes were rejected.

    Recorded by the check
    fact public-key-formats = spki-pem, x509
    evidence public-key-formats: SPKI DER bytes passed as the key: openssl_verify returned false
  • Private key formats

    pkcs8-pemencrypted-pkcs8sec1-pem

    PKCS#8 PEM, encrypted PKCS#8 and SEC1 EC PRIVATE KEY; openssl_pkey_export() writes PKCS#8.

    Recorded by the check
    fact private-key-formats = pkcs8-pem, encrypted-pkcs8, sec1-pem
    evidence private-key-formats: openssl_pkey_export writes -----BEGIN PRIVATE KEY-----

Usage

Sign with P-256 and show why openssl_verify() results must be compared with === 1.
<?php
$key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
$publicPem = openssl_pkey_get_details($key)['key'];
$message = 'order #1042 approved';

openssl_sign($message, $der, $key, OPENSSL_ALGO_SHA256); // ASN.1 DER

// openssl_verify returns 1 (valid), 0 (invalid) or -1 (error). -1 is truthy,
// so compare strictly with === 1, never with a loose if ($result).
$valid = openssl_verify($message, $der, $publicPem, OPENSSL_ALGO_SHA256);
$garbage = openssl_verify($message, random_bytes(64), $publicPem, OPENSSL_ALGO_SHA256);

echo 'valid: ', var_export($valid === 1, true), ', raw 64 bytes gives ', $garbage, ', which is truthy: ', var_export((bool) $garbage, true), "\n";

The snippet harness runs every sample verbatim in the container image php:8.5.11-cli-trixie, and the run fails unless the output matches.

Command
php usage.php 2>/dev/null
Output must match (regular expression)
valid: true, raw 64 bytes gives -1, which is truthy: true

Libraries