ecdsa.com

Library Interop Cheatsheet

You sign in library A and verify in library B — what breaks? Pick the two sides and get a checklist of the concrete mismatches: signature encoding, high-S handling, public key format and deterministic nonces, each with the line of code that fixes it.

Static reference data — no inputs, no network calls, nothing to send anywhere

openssl dgst / openssl pkeyutl

browsers, Deno, Node's webcrypto

Signer
Verifier
Library
OpenSSL CLI
WebCrypto (crypto.subtle)
Signature
ASN.1 DER
Raw r‖s (P1363)
Low-S
Accepts both · signs either
Accepts both · signs either
RFC 6979
Opt-in
No — random nonce
Public key
SPKI PEM
JWK
Curves
P-256, P-384, P-521, secp256k1
P-256, P-384, P-521

Signing in OpenSSL CLI → verifying in WebCrypto (crypto.subtle)

2 things to fix before this works
Breaks

Signature format mismatch: ASN.1 DER out, raw r‖s expected

OpenSSL CLI produces ASN.1 DER by default and WebCrypto (crypto.subtle) expects raw r‖s. Nothing about the key or the maths is wrong — the verifier simply cannot read the bytes, and a variable-length DER blob is the wrong length for a fixed-width parser.

Fix it on either side — the two encodings carry identical information, so converting is lossless and involves no cryptography.

Signer sideEmit raw r‖s from OpenSSL CLI

No built-in option

The CLI has no P1363 output for ECDSA. Take the two INTEGERs out of the DER structure and left-pad each to the curve size (32 bytes on P-256) — `openssl asn1parse -inform DER -in sig.der` prints r and s so you can check the conversion by eye.

Verifier sideAccept ASN.1 DER in WebCrypto (crypto.subtle)

No built-in option

There is no DER mode. Split the fixed-length output in half, strip leading zeros from each half, and wrap the two integers in a DER SEQUENCE before handing them to an OpenSSL-style verifier.

Behaviour varies between versions or builds — verify in your version.

See also: DER ⇄ Raw Converter · ECDSA signature formats: DER vs raw

Check this

Public key format mismatch: SPKI PEM vs JWK

OpenSSL CLI exports the public key as SPKI PEM, and WebCrypto (crypto.subtle) expects JWK. These are genuinely different containers: SPKI wraps the curve identifier around the point, a JWK spells out the x and y coordinates as base64url fields, and a SEC1 point is nothing but the coordinate bytes. The key material underneath is identical in all three.

OpenSSL CLI: `openssl ec -pubin -in pub.pem -pubout -outform DER` gives the same key as raw SPKI DER; `openssl ec -pubin -in pub.pem -text -noout` prints the uncompressed SEC1 point in hex.

WebCrypto (crypto.subtle): importKey("spki", derBytes, …) takes SPKI *DER*: strip the PEM header and footer and base64-decode first, because PEM text itself is rejected.

See also: ECDSA Signature Verifier

Good to know

Stay on P-256 / P-384 / P-521 — the curves both sides support

The curve sets do not match: secp256k1 only in OpenSSL CLI. Pick a key on a shared curve and this never comes up.

OpenSSL CLI: Everything the build enables: the NIST prime curves, secp256k1, Brainpool and more — `openssl ecparam -list_curves` is the authority for your binary.

WebCrypto (crypto.subtle): The three NIST curves only. secp256k1 is not part of the specification, so Bitcoin and Ethereum keys need a JavaScript library instead.

Good to know

OpenSSL CLI uses a random nonce unless you opt in to RFC 6979

The usual signing path uses a random nonce. Deterministic ECDSA (RFC 6979) exists in recent 3.x releases as a signature parameter, but the dgst and pkeyutl commands do not expose it.

This is correct behaviour, not a bug — but it means the same key and message produce different bytes every run, so a test that compares signatures byte-for-byte will fail. Compare verification results instead.

Behaviour varies between versions or builds — verify in your version.

Every library, side by side

Defaults only — every row can be changed with a flag or a different call. Scroll sideways for the full set of columns.

LibraryDefault signature formatHow to switchLow-S policyRFC 6979Default public key
OpenSSL CLI
openssl dgst / openssl pkeyutl
signer
ASN.1 DER
ASN.1 DER — a SEQUENCE of two INTEGERs, so a P-256 signature is usually 70–72 bytes and its length changes from signature to signature.
No raw r‖s mode in the CLI — convert the DER output externally.
Accepts both
No low-S policy in either direction: signatures come out with s in the upper half roughly half the time, and verification never cares.
Opt-in
The usual signing path uses a random nonce. Deterministic ECDSA (RFC 6979) exists in recent 3.x releases as a signature parameter, but the dgst and pkeyutl commands do not expose it.
SPKI PEM
SPKI PEM: the `-----BEGIN PUBLIC KEY-----` block written by `openssl ec -pubout`. Note that `-----BEGIN EC PUBLIC KEY-----` is not a thing — an EC-labelled block is a private key.
Node.js node:crypto
crypto.sign / crypto.verify / KeyObject
ASN.1 DER
ASN.1 DER unless you explicitly ask for the other encoding.
`dsaEncoding: "ieee-p1363"` on the key object — set it when signing *and* when verifying.
Accepts both
Accepts both halves and normalizes nothing — it is OpenSSL underneath.
No — random nonce
Random nonce: the same key and message produce different signature bytes on every call.
SPKI PEM
`publicKey.export({ type: "spki", format: "pem" })` is the common form; JWK is one call away with `export({ format: "jwk" })`.
WebCrypto (crypto.subtle)
browsers, Deno, Node's webcrypto
verifier
Raw r‖s (P1363)
Raw r‖s only, fixed length: 64 bytes on P-256, 96 on P-384, 132 on P-521. The specification defines no other encoding.
Nothing to switch — WebCrypto never speaks DER. Convert outside the API.
Accepts both
No low-S rule when signing or verifying.
No — random nonce
Random nonce, and the API gives you no way to supply your own.
JWK
You always name a format on import and export: "jwk", "spki" (DER bytes) or "raw" (the uncompressed SEC1 point). JWK is the form most web code passes around.
Go crypto/ecdsa
SignASN1 / VerifyASN1, or the big.Int pair
ASN.1 DER
`SignASN1` — the path `crypto.Signer` uses — returns ASN.1 DER. The lower-level `Sign` hands back r and s as `*big.Int`, which is neither wire format until you encode it.
`SignASN1`/`VerifyASN1` for DER; `Sign`/`Verify` plus `FillBytes` for fixed-width raw.
Accepts both
No low-S handling at all: verification accepts either half of the range.
No — random nonce
Not RFC 6979-compatible. Recent versions derive the nonce from the key, the message and fresh randomness together, so signature bytes differ on every run and cannot be pinned in tests.
SPKI DER
`x509.MarshalPKIXPublicKey` produces SPKI DER; a PEM block is one `pem.EncodeToMemory` away.
Python cryptography (pyca)
hazmat.primitives.asymmetric.ec
ASN.1 DER
DER — `private_key.sign(...)` returns the ASN.1 SEQUENCE.
`decode_dss_signature` / `encode_dss_signature` move between DER and the integer pair; the fixed-width padding is yours to add.
Accepts both
Accepts both halves; nothing normalizes s.
Opt-in
Random nonce by default. Recent releases accept a deterministic-signing flag on the ECDSA object, and that path needs a recent OpenSSL underneath — check what your wheel was built against before relying on it.
SPKI PEM
`public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)` — the same PEM block OpenSSL writes.
Python ecdsa (pure Python)
SigningKey / VerifyingKey, sigencode= and sigdecode=
Raw r‖s (P1363)
Raw r‖s: `sign()` returns the two values concatenated and zero-padded to the curve size.
`sigencode=` and `sigdecode=` arguments pick the encoding on every sign and verify call.
Accepts both
Both halves accepted. The library also ships canonicalizing encoders that force s into the low half — they exist alongside the plain ones and have to be selected explicitly.
Opt-in
Deterministic RFC 6979 signing is available as a separate signing method; the plain sign() call uses randomness.
SEC1 point
Keys travel as the bare point: `VerifyingKey.to_string()` returns x‖y with no 0x04 prefix by default, which SPKI-based stacks will not accept as-is.
Java JCA (SunEC)
java.security.Signature
ASN.1 DER
`SHA256withECDSA` produces ASN.1 DER.
Append `inP1363Format` to the algorithm name — same class, different wire format.
Accepts both
No low-S policy in either direction.
No — random nonce
SunEC signs with a random nonce. Deterministic ECDSA is reachable by adding a third-party provider such as Bouncy Castle.
SPKI DER
`publicKey.getEncoded()` returns X.509 SPKI DER. The JDK has no PEM support — the base64 wrapping is on you.
Rust p256 / k256 (RustCrypto)
ecdsa::Signature, SigningKey, VerifyingKey
Raw r‖s (P1363)
The default `Signature` type is the fixed-width pair — 64 bytes on P-256 and secp256k1. DER is a separate, explicit conversion.
The fixed-width pair is the default type; DER conversion is explicit in both directions.
Accepts both
The secp256k1 crate normalizes s into the low half when signing, matching the Bitcoin convention; the NIST-curve crates do not. Verification accepts both, so normalize on the signing side when the other end is strict.
Yes, by default
Deterministic RFC 6979 nonces by default — the same key and message always produce the same bytes. A randomized variant is opt-in.
SEC1 point
A `VerifyingKey` serializes to a SEC1 encoded point — 33 bytes compressed, 65 uncompressed. SPKI DER and PEM are available through the companion SPKI/PKCS#8 traits.
libsecp256k1 (Bitcoin Core)
secp256k1_ecdsa_sign / _verify
You choose
Neither, until you serialize: a signature lives in an opaque internal struct and you pick compact (raw r‖s) or DER on the way out.
Serialize compact for raw r‖s, DER for the Bitcoin wire format — the choice is per call.
Enforces low-S
The strict one. Signing always emits low-S, and verification rejects a high-S signature outright — the library exposes a normalization call precisely so you can fix signatures that arrived from a general-purpose signer.
Yes, by default
RFC 6979 by default; a different nonce function can be passed in if you need one.
SEC1 point
SEC1 points: 33 bytes compressed or 65 bytes uncompressed. No SPKI, no PEM, no JWK anywhere in the API.
PHP openssl_*
openssl_sign / openssl_verify
ASN.1 DER
DER — `openssl_sign()` hands back exactly what OpenSSL produced.
No raw mode at all: convert in userland, or use a JOSE library that already does it.
Accepts both
No low-S policy — it inherits OpenSSL's behaviour unchanged.
No — random nonce
Random nonce; the extension exposes no deterministic mode.
SPKI PEM
PEM strings in and out: `openssl_pkey_get_public()` takes a `-----BEGIN PUBLIC KEY-----` block or a whole certificate.
Ruby OpenSSL
OpenSSL::PKey::EC
ASN.1 DER
DER — `key.sign(digest, data)` returns the ASN.1 SEQUENCE.
No built-in raw mode: convert by hand through OpenSSL::ASN1, or let a JOSE gem do it.
Accepts both
No low-S policy — OpenSSL's behaviour, unchanged.
No — random nonce
Random nonce; no deterministic option is exposed.
SPKI PEM
SPKI PEM. Recent versions provide a dedicated public-key export method — worth using, because plain `to_pem` on a key that holds a private part writes the private key.

Behaviour varies between versions or builds — verify in your version.

Take it further

How it works

A signature that verifies in one library and fails in another is almost never wrong mathematically: the two sides simply disagree about packaging. An ECDSA signature is a pair of integers (r, s), and each ecosystem picked its own way to serialize them — ASN.1 DER in the OpenSSL and X.509 lineage, fixed-width r‖s in WebCrypto, JWS and most blockchain stacks. On top of that sit two more sources of disagreement: whether s must be in the lower half of the range (the low-S rule that Bitcoin enforces and general-purpose libraries ignore), and how the public key itself is wrapped — an SPKI structure, a JWK object, or the bare SEC1 point.

This page compares those defaults for a pair of libraries and lists only the differences that actually cause a failure, together with the switch or conversion that removes each one. Every row describes what a library does when you call the obvious API without extra arguments — not what it is capable of. Nearly all of it is configurable, which is exactly why two teams reading the same documentation end up incompatible, and why the fix is usually one option rather than new code.