JWS Implementation with Elixir and JOSE
This article is part of a series on understanding the hows and whys of JSON Web Signatures (JWS).
There's accompanying code: it's refered to and linked throughout the content. But if you'd rather just read raw code, head over here.
Let's build an authorization message you can prove in arbitration six years from now.
We've established why non-repudiation matters and how JWS gives you cryptographic proof of what scheme participants sent. Now it's time to actually implement it. But before diving into code, we need to make critical decisions about wire formats, understand the footguns that will bite you in production, and build this right the first time.
We'll go over JWS implementation in Elixir using the JOSE library, but the concepts apply to any language. More importantly, it explains the why behind each decision, enabling you to make informed choices for your own implementation context.
The Wire Format Decision: How Should You Serialize JWS?
JWS defines three serialization formats, and choosing the wrong one will make your life harder. Let's understand what we're choosing between.
Format 1: Compact Serialization (The Dot-Separated Format)
This is what most people think of when they see a JWT:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhbW91bnQiOjUwMDAwLCJjdXJyZW5jeSI6IkVVUiIsIm1lcmNoYW50X2lkIjoiTVJDSEFOVDEyMzQ1Njc4In0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk...
It's three Base64URL-encoded parts separated by dots:
BASE64URL(Header).BASE64URL(Payload).BASE64URL(Signature)
When to use compact serialization:
- Passing tokens in HTTP headers (
Authorization: Bearer ...) - URL parameters or query strings
- Single-signature scenarios (one party signs)
- When minimizing size matters
- OAuth 2.0 / OpenID Connect tokens
Drawbacks:
- Not human-readable (everything is Base64URL-encoded)
- Harder to debug (need to decode to see what's inside)
- Only supports a single signature
- Can't include unprotected headers (metadata outside the signature)
Format 2: Flattened JSON Serialization (The Structured Format)
This uses a JSON object structure:
{
"payload": "eyJhbW91bnQiOjUwMDAwLCJjdXJyZW5jeSI6IkVVUiIsIm1lcmNoYW50X2lkIjoiTVJDSEFOVDEyMzQ1Njc4In0",
"protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXUyIsImtpZCI6Imlzc3Vlci1rZXktMjAyNC0xMSJ9",
"signature": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk..."
}
When to use flattened JSON:
- Scenarios where the JWS is the message body
- When you want to include unprotected metadata for routing
- When debugging/transparency matters
Advantages:
- More readable during development
- Can include
headerfield for unprotected metadata - Easier to inspect without decoding
- Better for audit logs (clear structure)
Drawbacks:
- Larger payload size
- Not suitable for HTTP headers
- Still only single signature
Format 3: General JSON Serialization (Multi-Signature)
This supports multiple signatures over the same payload:
{
"payload": "eyJhbW91bnQiOjUwMDAwLCJjdXJyZW5jeSI6IlVTRCJ9",
"signatures": [
{
"protected": "eyJhbGciOiJFUzI1NiJ9",
"signature": "dBjftJeZ4..."
},
{
"protected": "eyJhbGciOiJSUzI1NiJ9",
"signature": "cC4hiUPoj9..."
}
]
}
When to use general JSON:
- Multiple parties must sign the same payload
- Escrow scenarios
- Multi-party approval workflows
- Smart contracts or blockchain bridges
For most authorization APIs, you'll rarely need this. It's overkill for single-signer scenarios.
What We'll Use: Flattened JSON
Because in our messages the JWS will be the request body itself, we'll use flattened JSON serialization (see here). Here's why:
- Clarity in disputes: When you're resolving a chargeback dispute months later, being able to inspect the signed payload without decoding Base64 in convenient
- Easier debugging: During development and testing, you can read the authorization structure directly
- Better audit trails: Your logs are more readable when the JWS structure is clear
- Metadata support: You can include unprotected headers for things like message routing or trace context
Use compact serialization for access tokens, authorization headers, and scenarios where size matters. Use flattened JSON for authorization approval/decline messages.
Implementation: Setting Up Elixir and JOSE
Let's build the real thing with
mix new demo
. First, add JOSE to your mix.exs:
def deps do
[
{:jose, "~> 1.11"},
{:jason, "~> 1.4"} # For JSON encoding/decoding
]
end
Run
mix deps.get
and you're ready.
Generating Keys
First, you need key pairs. In production, you'll use an HSM or cloud KMS, but for this example, we'll generate locally:
mkdir -p priv/keys
cd priv/keys
# Generate an EC (P-256) key pair for ES256
openssl ecparam -name prime256v1 -genkey -noout -out private-key.pem
openssl ec -in private-key.pem -pubout -out public-key.pem
ls
# you should see:
# private-key.pem public-key.pem
Load them in Elixir:
defmodule Demo.SchemeKeys do
@key_path Path.join("priv", "keys")
def load_key(name) do
@key_path
|> Path.join(name)
|> JOSE.JWK.from_pem_file()
end
def load_private_key(), do: load_key("private-key.pem")
def load_public_key(), do: load_key("public-key.pem")
end
Signing a Payload
Here's how to sign an authorization instruction with a JWS flattened format:
defmodule Demo.PayloadSigner do
@moduledoc """
Signs payloads using JWS compact serialization
"""
def sign_payload(%{} = payload, jwk) do
# Construct the authorization payload
# Include all critical fields that must be proven
payload = Map.merge(payload, %{
# Timestamps for replay prevention
iat: DateTime.utc_now() |> DateTime.to_unix(),
exp: DateTime.utc_now()
|> DateTime.add(300, :second)
|> DateTime.to_unix()
})
# Configure the JWS header
jws_header = %{
"alg" => "ES256",
"typ" => "JWS",
# Key ID for rotation support
"kid" => "issuer-key-2024-11"
}
# encode and sign the payload
encoded_payload = Jason.encode!(payload)
signed = JOSE.JWS.sign(jwk, encoded_payload, jws_header)
# Get compact serialization
{%{alg: :jose_jws_alg_ecdsa}, compact_jws} =
JOSE.JWS.compact(signed)
# Convert compact to flattened JSON structure
[header_b64, payload_b64, signature_b64] =
String.split(compact_jws, ".")
flattened = %{
"payload" => payload_b64,
"protected" => header_b64,
"signature" => signature_b64
}
{:ok, flattened}
end
end
Let's take it for a quick spin in
iex -S mix
:
iex(1)> pk = Demo.SchemeKeys.load_private_key
#JOSE.JWK<keys: :undefined, fields: %{}, ...>
iex(2)> Demo.PayloadSigner.sign_payload(%{foo: "bar"}, pk)
{:ok,
%{
"payload" => "eyJleHAiOjE3NjYzOTY0MjgsImZvbyI6ImJhciIsImlhdCI6MTc2NjM5NjEyOH0",
"protected" => "eyJhbGciOiJFUzI1NiIsImtpZCI6Imlzc3Vlci1rZXktMjAyNC0xMSIsInR5cCI6IkpXUyJ9",
"signature" => "Nt_1CXjNObAt-7gz5DX55K96-6sf0P3XIxBJKM2gcY5gwTsDNG47QTCDF41rhS_K5pH0GScOC9-nrqhQkuarFA"
}}
Key points:
- Include all critical data in the payload: Amount, currency, beneficiary details, reference—everything that matters for disputes
- Add timestamps:
iat(issued at) andexp(expiration) help prevent replay attacks - Use a meaningful
kid: Supports key rotation without breaking verification - Use Jason.encode! consistently: JSON serialization must be identical during signing and verification
The accompanying code base has a more complete signer implementation, including JWS compact form.
Verifying JWS
Now for the receiving side. Verification is where most mistakes happen, so careful implementation is critical.
Basic Verification
defmodule Demo.PayloadVerifier do
@moduledoc """
Verifies JWS signatures on payloads
"""
def verify_and_decode(jws, public_jwk) do
# Verify the signature and extract payload
# CRITICAL: Explicitly whitelist allowed algorithms,
# never trust the alg from the token!
# Here, we only accept ES256
case JOSE.JWS.verify_strict(public_jwk, ["ES256"], jws) do
{true, payload, _jws} -> {:ok, Jason.decode!(payload)}
_ -> {:error, :invalid_signature}
end
rescue
error ->
{:error, {:verification_failed, error}}
end
end
Critical security note: The
JOSE.JWS.verify_strict/3
function takes an explicit list of allowed algorithms. Never trust the
alg
field from the JWS header itself: this is
how algorithm
substitution attacks work.
And let's poke at it in
iex -S mix
(note that the
jws
comes from copying the output from our signature test above):
iex(1)> jws = %{
...(1)> "payload" => "eyJleHAiOjE3NjYzOTY0MjgsImZvbyI6ImJhciIsImlhdCI6MTc2NjM5NjEyOH0",
...(1)> "protected" => "eyJhbGciOiJFUzI1NiIsImtpZCI6Imlzc3Vlci1rZXktMjAyNC0xMSIsInR5cCI6IkpXUyJ9",
...(1)> "signature" => "Nt_1CXjNObAt-7gz5DX55K96-6sf0P3XIxBJKM2gcY5gwTsDNG47QTCDF41rhS_K5pH0GScOC9-nrqhQkuarFA"
...(1)> }
%{
"payload" => "eyJleHAiOjE3NjYzOTY0MjgsImZvbyI6ImJhciIsImlhdCI6MTc2NjM5NjEyOH0",
"protected" => "eyJhbGciOiJFUzI1NiIsImtpZCI6Imlzc3Vlci1rZXktMjAyNC0xMSIsInR5cCI6IkpXUyJ9",
"signature" => "Nt_1CXjNObAt-7gz5DX55K96-6sf0P3XIxBJKM2gcY5gwTsDNG47QTCDF41rhS_K5pH0GScOC9-nrqhQkuarFA"
}
iex(2)> pub_key = Demo.SchemeKeys.load_public_key()
#JOSE.JWK>keys: :undefined, fields: %{}, ...>
iex(3)> Demo.PayloadVerifier.verify_and_decode(jws, pub_key)
{:ok, %{"exp" => 1766396428, "foo" => "bar", "iat" => 1766396128}}
The accompanying code base has a more complete verifier implementation
Building a Production Phoenix Plug
If you're going to be verifying the signatures for all incoming JWS payloads, it would make sense to extract this logic into a Phoenix Plug. Rejoice: we've done just that! Head over to the accompanying code to see the plug itself and where it's used in the router.
The Footguns They Forget to Tell You About
Now for some painful lessons to save you from learning them in production yourself.
Footgun #1: JSON Whitespace and Canonicalization
The problem:
{"amount":100}
and
{"amount": 100}
produce different signatures.
When you sign JSON data, you're signing the exact bytes. If the JSON serialization changes between signing and verification, the signature breaks.
# This will fail signature verification:
payload1 = Jason.encode!(%{amount: 100}, pretty: false)
# -> "{\"amount\":100}"
payload2 = Jason.encode!(%{amount: 100}, pretty: true)
# -> "{\n \"amount\": 100\n}"
# payload1 and payload2 are different byte sequences
# (note the whitespace)
# They'll produce different signatures!
The solution: Use consistent JSON serialization everywhere. Never pretty-print JSON before signing. Document your serialization library and version.
# ALWAYS use the same serialization approach
defmodule PaymentJSON do
@doc "Canonical JSON encoding for JWS signatures"
def encode!(data) do
# Use compact encoding, no whitespace
Jason.encode!(data, pretty: false)
end
end
# Use this everywhere for JWS payloads
payload_string = PaymentJSON.encode!(authorization_data)
See this in context in the accompanying signer code
Footgun #2: Base64URL vs Base64
The problem: Standard Base64 uses
+
and
/
, which break URLs. Base64URL uses
-
and
_
instead.
# Standard Base64 (wrong for JWS)
iex(1)> Base.encode64("???")
"Pz8/"
iex(2)> Base.url_encode64("???")
"Pz8_"
JOSE handles this internally, but if you're manually working with JWS parts
(don't do this unless necessary), use
Base.url_encode64/2
and
Base.url_decode64/2
.
Footgun #3: Clock Skew Will Bite You Eventually
Production systems have clock drift, that's just a fact of life: always allow clock skew tolerance (5 minutes is industry standard).
Trust me, you don't want to be debugging production incidents where an issuer's server clock is 2 minutes slow, causing all their requests to fail validation. The 5-minute window prevents this.
# Don't do strict timestamp checks
if exp < now, do: {:error, :expired}
# Allow skew
if exp < now - @skew_allowance_seconds, do: {:error, :expired}
See this in context in the accompanying verifier code
Footgun #4: Key Rotation Without Downtime
You'll need to rotate keys eventually. Support this from day one by including
kid (Key ID) in your JWS headers and using JSON Web Key Set (JWKS) endpoints for key
distribution. We'll cover this in depth when we discuss
JWKS and key rotation,
but the short version:
- Always include
kidin your JWS header - Publish public keys at a JWKS endpoint
- During rotation, publish both old and new keys
- Switch signing to the new key
- Wait for all old tokens to expire
- Remove the old key from JWKS
Don't hard-code public keys in your verification code. Fetch them from a JWKS endpoint with appropriate caching.
Footgun #5: Storing Only the Payload, Not the JWS
Wrong approach:
# BAD: Only storing parsed data
%AuditLog{
payload: Jason.decode!(jws_payload),
# Missing: original JWS string
}
Right approach:
# GOOD: Storing both original JWS and parsed data
%AuditLog{
jws_signature: original_jws_string, # For re-verification
payload: Jason.decode!(jws_payload), # For querying
}
Six months from now, when you need to re-verify a signature for a dispute, you'll need that original JWS string. We'll talk about audit trails in greater detail later.
Wrapping Up
You now have working code for JWS signing and verification in Elixir, a production-ready Phoenix Plug , and awareness of the footguns that cause production incidents. But several critical questions remain:
- Why ES256 specifically? What are the alternatives and their trade-offs?
- How do we ensure ES256 is actually secure for financial use cases?
- What attacks do we need to defend against?
- How do organizational constraints affect algorithm choice?
Next up, cryptographic hygiene: decoding algorithm names like RS256 and ES384, understanding what makes them secure (or insecure), defending against algorithm substitution attacks, and building a decision framework that considers both technical merit and organizational reality.
The right algorithm choice depends on your threat model, compliance requirements, team expertise, and infrastructure constraints. Not just what appears in tutorials.
This article is part of a series on understanding the hows and whys of JSON Web Signatures (JWS).
There's accompanying code: it's refered to and linked throughout the content. But if you'd rather just read raw code, head over here.