A signature is two integers
Whatever bytes you see on the wire, an ECDSA signature is mathematically just a pair of positive integers (r, s), each between 1 and n − 1, where n is the order of the curve (a number close to 2256 for P-256 and secp256k1). Everything else is serialization — and the ecosystem settled on two incompatible ways to do it. Neither contains any information the other lacks; converting between them is lossless and purely mechanical.
Format 1: ASN.1 DER — the X.509 lineage
The format OpenSSL, X.509 certificates and TLS use comes from the ASN.1 world: the pair is encoded as a DER SEQUENCE of two INTEGERs. An annotated P-256 example:
30 45 SEQUENCE, 0x45 = 69 bytes follow
02 21 INTEGER, 0x21 = 33 bytes (this is r)
00 c8 7f 3b ... 33 bytes: leading 00 because r's top bit is set
02 20 INTEGER, 0x20 = 32 bytes (this is s)
6a 12 09 ... 32 bytes: no padding needed, top bit is clearDER's integer rules are what make the format slightly tricky:
- Integers are big-endian, two's-complement, and minimal length: leading zero bytes must be stripped.
- But the values are positive — so if the most significant bit of the first byte is set (first byte ≥
0x80), a single0x00must be prepended to keep the number from reading as negative. Each ofrandsindependently may or may not carry this pad byte. - The result is a variable-length encoding: for P-256, typically 70, 71 or 72 bytes (and occasionally shorter, when
rorshappens to start with zero bytes). Code that assumes a fixed signature length is a bug waiting for the wrong random value. - DER is the distinguished encoding: exactly one valid byte string per value. Strict parsers reject non-minimal integers, out-of-spec padding, or trailing garbage — Bitcoin made strict DER a consensus rule (BIP-66) precisely to pin down parser disagreements.
Format 2: raw r‖s — IEEE P1363, the fixed-length one
The other convention drops ASN.1 entirely: pad r and s each to exactly the curve's coordinate size, big-endian, and concatenate. This is often labeled IEEE P1363 format (after the standard that used it), "raw", or "flat" r‖s:
| Curve | Field element size | Raw signature size |
|---|---|---|
| P-256 / secp256k1 | 32 bytes | 64 bytes |
| P-384 | 48 bytes | 96 bytes |
| P-521 | 66 bytes | 132 bytes |
Fixed length, no tags, no padding rules — split the blob in half and you have your integers. This is the format WebCrypto emits, the format RFC 7518 §3.4 mandates for JWS (and therefore JWTs — the practical fallout is covered in ES256 vs RS256 for JWTs), and the shape most blockchain stacks work with internally: Ethereum signatures are the 65-byte r‖s‖v (raw pair plus a recovery byte), while Bitcoin, as noted above, encodes transaction signatures in strict DER.
The failure mode is symmetric and extremely common: a 64-byte raw signature handed to a DER-expecting API dies at the ASN.1 parser (0x30 expected), while a ~71-byte DER blob handed to WebCrypto or a JWS verifier is simply the wrong length and fails cleanly with a generic "invalid signature". When two stacks disagree, convert explicitly — the DER ⇄ raw converter does it both ways in the browser and shows the decoded r and s.
High-S, low-S, and why a valid signature gets rejected
Even with matching encodings, one more subtlety breaks cross-stack verification. ECDSA has an inherent symmetry: if (r, s) is a valid signature, then (r, n − s) is a valid signature for the same message under the same key. Anyone holding a signature can flip it to the other form without the private key — the signature bytes are malleable even though the signed content is not.
For protocols that identify data by hashing the bytes of signatures — Bitcoin transaction IDs being the canonical example — this third-party malleability is a real problem. The fix is canonical low-S form: require s ≤ n/2, and normalize by replacing s with n − s otherwise. Bitcoin adopted low-S as a relay rule (proposed in BIP-62 and specified in BIP-146), and libsecp256k1's verification accepts only low-S signatures unless the caller normalizes first. Some security-focused libraries in other ecosystems also produce low-S by default and can be configured to reject high-S on verify.
Who speaks what: library defaults
| Library / API | Default format | The other format |
|---|---|---|
| OpenSSL (CLI and EVP API) | DER | No built-in raw mode for ECDSA; convert externally or assemble from ECDSA_SIG's r and s |
Node.js node:crypto | DER | dsaEncoding: "ieee-p1363" option on sign/verify (available since the Node 12 LTS line) |
WebCrypto crypto.subtle | Raw (P1363) only | No DER support — convert before/after |
Go crypto/ecdsa | Neither: Sign/Verify take r, s as *big.Int | SignASN1 / VerifyASN1 for DER (Go 1.15+); build raw with FillBytes |
Python cryptography (pyca) | DER | decode_dss_signature / encode_dss_signature helpers expose r and s |
Python ecdsa package | Raw by default | sigencode_der / sigdecode_der arguments |
| Java (JCA Signature) | DER (SHA256withECDSA) | SHA256withECDSAinP1363Format in modern JDKs (9+) |
A few of these in code. Node.js can emit either encoding from the same key:
import { generateKeyPairSync, sign } from "node:crypto";
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const msg = Buffer.from("same signature, two encodings");
const der = sign("sha256", msg, privateKey);
const raw = sign("sha256", msg, { key: privateKey, dsaEncoding: "ieee-p1363" });
console.log(der.length); // 70..72 (varies per signature)
console.log(raw.length); // always 64Go makes the underlying integers explicit, which also makes conversion obvious — note FillBytes for the fixed-width, zero-padded raw halves:
der, err := ecdsa.SignASN1(rand.Reader, priv, digest) // ASN.1 DER
if err != nil { return err }
r, s, err := ecdsa.Sign(rand.Reader, priv, digest) // the integers themselves
if err != nil { return err }
raw := make([]byte, 64)
r.FillBytes(raw[:32]) // zero-pads on the left — critical for short values
s.FillBytes(raw[32:])Python's pyca cryptography signs in DER, with helpers to reach the integers:
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
der_sig = private_key.sign(message, ec.ECDSA(hashes.SHA256())) # DER bytes
r, s = decode_dss_signature(der_sig)
raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big") # 64-byte P1363A debugging checklist
When an ECDSA signature refuses to verify across two systems, walk this list in order:
- Length test. Exactly 64/96/132 bytes → raw. Starts with
0x30and has a plausible length byte → DER. Anything else: wrong decoding of an outer layer (base64, hex) or a truncated copy-paste. - Format match. Does the verifying API expect what the signer produced? Convert with the DER ⇄ raw converter rather than re-signing.
- Zero-padding. If raw signatures fail intermittently (roughly 1 time in 256), suspect a conversion that dropped leading zero bytes instead of left-padding
randsto fixed width. - High-S. If failures are intermittent at roughly 50% and one side involves a blockchain or security-hardened stack, compare
swithn/2and normalize. - Everything else — wrong hash, wrong curve, hashing the hash — is the algorithm layer rather than the format layer; for that background, start with ECDSA vs RSA.
The encouraging summary: DER vs raw is pure plumbing. No cryptography is involved in the conversion, nothing about the key changes, and once both sides agree on the byte layout — and on low-S, where it matters — the "invalid signature" that cost an afternoon usually turns out to have been valid all along.