ecdsa.com
← All articles

ECDSA signature formats: DER vs raw (P1363), high-S and cross-library pitfalls

Your key is right, your message is right, your algorithm is right — and verification still fails. More often than not, the two sides simply disagree about how the bytes of the signature are arranged. This is the byte-level guide to the two encodings of an ECDSA signature, and to the high-S subtlety that breaks interop even when the encoding matches.

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:

DER-encoded ECDSA signature, annotated
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 clear

DER'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 single 0x00 must be prepended to keep the number from reading as negative. Each of r and s independently 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 r or s happens 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:

CurveField element sizeRaw signature size
P-256 / secp256k132 bytes64 bytes
P-38448 bytes96 bytes
P-52166 bytes132 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 / APIDefault formatThe other format
OpenSSL (CLI and EVP API)DERNo built-in raw mode for ECDSA; convert externally or assemble from ECDSA_SIG's r and s
Node.js node:cryptoDERdsaEncoding: "ieee-p1363" option on sign/verify (available since the Node 12 LTS line)
WebCrypto crypto.subtleRaw (P1363) onlyNo DER support — convert before/after
Go crypto/ecdsaNeither: Sign/Verify take r, s as *big.IntSignASN1 / VerifyASN1 for DER (Go 1.15+); build raw with FillBytes
Python cryptography (pyca)DERdecode_dss_signature / encode_dss_signature helpers expose r and s
Python ecdsa packageRaw by defaultsigencode_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:

node — one key, both formats
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 64

Go makes the underlying integers explicit, which also makes conversion obvious — note FillBytes for the fixed-width, zero-padded raw halves:

go — DER out, raw out
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:

python — DER to raw with pyca/cryptography
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 P1363

A 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 0x30 and 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 r and s to fixed width.
  • High-S. If failures are intermittent at roughly 50% and one side involves a blockchain or security-hardened stack, compare s with n/2 and 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.