JWS Implementation with Elixir and JOSE

Back to all posts

Posted on January 16, 2026

 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 header field 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:

  1. Clarity in disputes: When you're resolving a chargeback dispute months later, being able to inspect the signed payload without decoding Base64 in convenient
  2. Easier debugging: During development and testing, you can read the authorization structure directly
  3. Better audit trails: Your logs are more readable when the JWS structure is clear
  4. 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) and exp (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:

  1. Always include kid in your JWS header
  2. Publish public keys at a JWKS endpoint
  3. During rotation, publish both old and new keys
  4. Switch signing to the new key
  5. Wait for all old tokens to expire
  6. 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.