Skip to content
ecdsa.com

Sign an ES256 JWT · OpenSSL

How to sign a JWT with ES256 with OpenSSL

You can sign a JWT with ES256 using the OpenSSL command line and a few shell tools: base64url-encode the header and claims, sign header.payload with openssl dgst -sha256, and convert the DER signature that OpenSSL writes into the 64-byte r‖s form JWS requires. That conversion is the step most scripts get wrong.

Tested with OpenSSL 3.5.7 (Debian 13, with xxd and coreutils).

sign.sh — build, sign and assemble an ES256 JWT
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
header=$(printf '{"alg":"ES256","typ":"JWT","kid":"2026-10"}' | b64url)
now=$(date +%s)
payload=$(printf '{"iss":"https://issuer.example.com","sub":"user-42","aud":"api://orders","iat":%d,"exp":%d}' \
"$now" $((now + 900)) | b64url)
# openssl dgst writes DER (a SEQUENCE of two INTEGERs); JWS needs r‖s, 32 bytes each
printf '%s.%s' "$header" "$payload" | openssl dgst -sha256 -sign key.pem -out sig.der
rs=$(openssl asn1parse -inform DER -in sig.der | awk -F: '/INTEGER/ { printf "%64s", $NF }' | tr ' ' 0)
signature=$(printf '%s' "$rs" | xxd -r -p | b64url)
printf '%s.%s.%s\n' "$header" "$payload" "$signature"

How it works

  1. b64url is base64 without line breaks (-A), with +/ mapped to -_ and the = padding removed — the JWS encoding.
  2. The signing input is the ASCII text header.payload; openssl dgst -sha256 -sign key.pem hashes it and writes an ASN.1 DER signature.
  3. openssl asn1parse prints r and s as hex; awk left-pads each to 64 hex digits, so each half is exactly 32 bytes.
  4. xxd -r -p turns the 128 hex digits into 64 bytes, which become the third, base64url-encoded part of the token.

Gotchas

  • Base64url-encoding the DER signature directly produces a token every library rejects: the JWS signature is raw r‖s, never DER.
  • Padding matters: about one signature in 128 has an r or s shorter than 32 bytes. Concatenating the values without left-padding works in tests and fails at random in production — the run signs 200 tokens to cover that case.
  • -sha256 is required: ES256 is ECDSA with SHA-256 on P-256. openssl dgst -sign with a P-384 key still produces a signature, just not one any ES256 verifier accepts.
  • printf the JSON exactly as it should be signed. Any later re-serialization (spaces, key order) changes the bytes, and the signature covers bytes, not JSON semantics.

Related recipes