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 eachprintf '%s.%s' "$header" "$payload" | openssl dgst -sha256 -sign key.pem -out sig.derrs=$(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
b64urlis base64 without line breaks (-A), with+/mapped to-_and the=padding removed — the JWS encoding.- The signing input is the ASCII text
header.payload;openssl dgst -sha256 -sign key.pemhashes it and writes an ASN.1 DER signature. openssl asn1parseprints r and s as hex;awkleft-pads each to 64 hex digits, so each half is exactly 32 bytes.xxd -r -pturns 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.
-sha256is required: ES256 is ECDSA with SHA-256 on P-256.openssl dgst -signwith a P-384 key still produces a signature, just not one any ES256 verifier accepts.printfthe 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.