Audit Trails That Win Disputes

The "Forever" Proof

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.

You've implemented JWS signing. You verify signatures on every request. Your JWKS endpoint handles key rotation gracefully. Everything works beautifully in production.

Then, two years later, your company receives a fraud investigation notice. A €30'000 payment was disputed as unauthorized by a cardholder. The payment went through your payment scheme, and now the issuer (the cardholder's bank) is claiming fraud and refusing to pay. They're demanding the acquirer (the merchant's bank) cover the loss.

The acquirer's fraud team contacts you: "We need proof the issuer authorized this payment with SCA. Under PSD2, if Strong Customer Authentication was performed and the issuer approved it, liability shifts to them. But they're claiming they never authorized it. Can you prove they did?"

You pull up your database. The authorization record exists. The logs show the API call happened. But when you try to re-verify the JWS signature to generate cryptographic proof, it fails. Why? Because somewhere along the way, your system pretty-printed the JSON before storing it, changing the whitespace. The signature is no longer valid.

You have the data, but you can't prove it came from the issuer. The acquirer is now liable for €30'000 – and your company's contract with them says you're responsible for maintaining tamper-proof audit trails. You've just found out the hard way that your audit trail couldn't survive a dispute.

This is the difference between implementing JWS and implementing it correctly for the long term. Non-repudiation isn't just about signing requests, it's about preserving that proof in a form that survives time, disputes, system migrations, and regulatory audits.

In this final post, we'll cover the operational discipline required to build audit trails that actually work when you need them years later.

The Storage Imperative: What You Must Preserve

When a signed authorization message arrives, you need to store specific data. Miss any of these, and your proof evaporates.

Critical Requirement 1: Store the original JWS string

You cannot reconstruct the original JWS from parsed data. JSON serialization isn't deterministic: {"amount":100} and {"amount": 100} are semantically identical but produce different signatures. If you only store the parsed payload, you lose the ability to re-verify the signature years later.

Critical Requirement 2: Store the partner's public key

Once a partner rotates their keys, you won't be able to verify signatures that reference key IDs no longer published in their JWKS. You need to store a snapshot of the public key at verification time: this will enable you to re-verify even years after the partner has rotated to new keys.

Complete audit logging example:

  
  
    
defmodule AuthorizationAudit do
  def log_instruction(conn, authorization_data, public_key_jwk) do
    %AuditLog{
      # CRITICAL 1: Store the original JWS string
      jws_signature: conn.assigns.original_jws,

      # CRITICAL 2: Store public key used at verification time
      partner_public_key: public_key_jwk,

      # Also store parsed data for querying
      payload: authorization_data,

      # Metadata for context
      partner_id: conn.assigns.partner_id,
      instruction_id: authorization_data["instruction_id"],
      verified_at: DateTime.utc_now(),
      verification_algorithm: "ES256",
      ip_address: get_ip(conn),
      request_id: Logger.metadata()[:request_id]
    }
    |> Repo.insert!()
  end
end

    
  

In our example app: log_authorization/3 implementation.

The Trust Anchor Problem: Proving the Public Key is Authentic

Don't forget: cryptography can prove a signature is valid for a given public key, but it cannot prove that the public key belongs to a specific real-world entity.

If you can't prove the public key authentically came from the issuer, they can claim: "That's not our key. You generated that key pair yourself and are fabricating evidence."

Production approaches to establishing trust:

1. HTTPS/TLS + Domain Ownership Fetch JWKS from the issuer's domain over HTTPS. TLS certificate chain proves domain ownership, verified by certificate authorities. The issuer registers their JWKS URL during onboarding (e.g., https://issuer-bank.com/.well-known/jwks.json). Fetching keys from that URL over HTTPS means you trust whoever controls that domain. This is the primary mechanism.

2. Contractual Obligations Onboarding contracts specify the JWKS URL and include legal attestation: "All public keys published at this URL are under our exclusive control and legally binding." Creates legal liability for key ownership and shifts burden of proof to the issuer.

3. Out-of-Band Verification During Onboarding When an issuer first registers, verify key fingerprints through separate channels: phone calls to registered contacts, video conferences, emails to verified addresses. Establishes the initial trust anchor. After that, HTTPS/TLS maintains trust for routine operations.

4. Key Continuity (Uncommon) During rotation, the trusted old key can sign a vouching statement for the new key. Common in PGP/GPG and PKI certificate chains, but rare for JWKS in payment schemes. Doesn't eliminate the need for out-of-band verification, but can strengthen audit trails.

What to store for provenance:

  
  
    
%AuditLog{
  # ... other fields ...

  key_provenance: %{
    jwks_url: "https://issuer-bank.com/.well-known/jwks.json",
    fetched_at: ~U[2024-11-15 10:30:00Z],
    tls_cert_fingerprint: "SHA256:abc123...",
    key_fingerprint: "SHA256:def456...",
    issuer_registration_id: "REG-2024-001",
    verification_method: "out_of_band_during_onboarding"
  }
}

    
  

The reality is that out-of-band verification is always required at the initial trust establishment. Cryptography proves the signature is valid; organizational processes (domain ownership + contracts + onboarding verification) prove the key belongs to the issuer. Production schemes use HTTPS/TLS as the primary day-to-day mechanism, with out-of-band verification typically reserved for onboarding and emergency rotations.

Store Verification Metadata

Document the verification process itself:

  
  
    
%AuditLog{
  jws_signature: original_jws,
  payload: authorization_data,

  # Verification metadata
  verified_at: DateTime.utc_now(),
  verification_algorithm: "ES256",
  verification_kid: "partner-2024-11",
  verification_result: "success",

  # Issuer information
  partner_id: conn.assigns.partner_id,
  partner_public_key: public_key_jwk
}

    
  

This metadata proves:

  • When you verified the signature
  • Which algorithm and key you used
  • That verification succeeded at that moment

The Canonicalization Trap: Whitespace Matters

This is the oversight that breaks more audit trails than any other.

The problem:

  
  
    
# Issuer signs this:
orig_payload = ~s({"amount": 100,"currency": "EUR"})
# {"amount": 100,"currency": "EUR"}

# You store this (pretty-printed):
parsed_payload = Jason.encode!(Jason.decode!(orig_payload))
# {"amount":100,"currency":"EUR"}

# Different bytes = different signature
# => Verification fails when you try to re-verify later

    
  

Even though the JSON is semantically identical, the byte sequences differ. Signatures are computed over exact bytes, so verification fails.

Never pretty-print JSON before signing, and store the original JWS string.

Document your serialization approach:

  
  
    
# JWS Canonicalization Policy

All JWS signatures use the following JSON serialization:
- Library: Jason 1.4.x
- Encoding: UTF-8
- Whitespace: None (compact)
- Key ordering: Lexicographic (default)
- Escape mode: unicode_safe

DO NOT change these settings without a migration plan, as it will
break signature verification on existing audit records.

    
  

And use this consistent encoding implementation in your code:

  
  
    
defmodule PaymentJSON do
  @doc "Canonical JSON encoding for JWS signatures"
  def encode!(data) do
    # Compact encoding, no whitespace, consistent key ordering
    Jason.encode!(data,
      escape: :unicode_safe,
      pretty: false
    )
  end
end

# Always use this for JWS payloads
defmodule AuthorizationSigner do
  def sign_instruction(authorization_data, private_key) do
    # Use canonical encoding
    payload_string = PaymentJSON.encode!(authorization_data)

    # Sign
    JOSE.JWS.sign(private_key, payload_string, %{"alg" => "ES256", "kid" => "..."})
  end
end

    
  

Bidirectional Audit Trails: Tracking Outbound Requests

The previous sections focused on inbound audit trails—storing signed requests you receive from partners. But what about requests you send to partners?

For complete non-repudiation, you need bidirectional audit trails:

  • Inbound: Partners can't deny sending you requests
  • Outbound: Partners can't deny the content of their responses to your requests

Why Track Outbound Requests?

When you send webhooks or API calls to partners (e.g., payment notifications, transaction updates), you want cryptographic proof that the partner received and acknowledged it. Example code

Example scenario: You send a payment authorization request and the issuer approves it. Later, they claim the authoriztaion was never approved. Your outbound audit log proves otherwise: you have the partner's signed response, and timestamps.

Why This Matters

With bidirectional audit trails, you have complete proof of all signed interactions:

  • Regulatory compliance: Demonstrate you have complete audit trail for all transactions
  • Dispute resolution: Prove what you sent and what partners acknowledged
  • Debugging: Track complete request/response lifecycle for troubleshooting
  • SLA tracking: Monitor partner response times and success rates

The same re-verification (and OpenSSL verification, as introduced below) processes work for both directions: the cryptographic proof is identical whether you received the signed message or sent it.

The Dispute Playbook: Responding to Challenges

When a partner disputes a payment, you need to retrieve stored cryptographic proof and present it in a form that regulators and auditors can independently verify. The process has four steps:

Step 1: Retrieve the Audit Record

Query your audit log by instruction_id to fetch the stored JWS signature, partner's public key, and verification metadata. This gives you everything needed for re-verification.

Step 2: Re-Verify the Signature

Reconstruct the partner's public key from the stored JWK and re-verify the original JWS signature. This proves the signature is still valid years later, confirming the payload hasn't been modified and was signed by the partner's private key.

Step 3: Generate Dispute Evidence

Create an evidence report containing: the original JWS, the partner's public key (in both JWK and PEM formats), the decoded payload, verification timestamps (original and dispute-time), and a proof statement explaining what the valid signature proves in non-technical terms.

Step 4: Package Evidence for Distribution

Bundle the evidence into a verification package for auditors or legal teams: the original JWS, public key files, decoded payload, and step-by-step instructions for independent verification using OpenSSL. Include a README explaining what "Verified OK" means in legal terms.

Key principle: Regulators and judges aren't cryptographers. Your evidence package must explain why a valid signature means the partner authorized the instruction. Analogies such as "Like a handwritten signature, but mathematically impossible to forge." may be helpful in that regard.

In code:

Independent Verification with OpenSSL

A critical aspect of non-repudiation is that anyone can verify the signature using standard cryptographic tools. This section walks through verifying a JWS signature using OpenSSL, demonstrating that verification doesn't depend on your scheme's code or trust in your systems.

The Scenario

An issuer disputes an authorization from 2021. You've generated the verification package (from Step 4 above) and provided the tarball to an independent auditor.

The auditor extracts the package:

  
  
    
tar -xzf verification_AUTH-2021-12345.tar.gz
cd verification_AUTH-2021-12345
ls
# jws_original.txt payload_decoded.json
# public_key.pem public_key.jwk VERIFICATION.md

    
  

The package contains everything needed for independent verification. The auditor follows these steps:

Verification Steps

Step 1: Extract JWS components

ES256 signatures are computed over {header}.{payload} (the signing input). Extract the components:

  
  
    
# Read the JWS
JWS=$(cat jws_original.txt)

# Split into header, payload, and signature
HEADER=$(echo $JWS | cut -d'.' -f1)
PAYLOAD=$(echo $JWS | cut -d'.' -f2)
SIGNATURE=$(echo $JWS | cut -d'.' -f3)

# Create signing input (what was actually signed)
echo -n "${HEADER}.${PAYLOAD}" > signing_input.txt

    
  

Step 2: Decode and convert the signature

JWS uses Base64URL encoding and raw ECDSA signature format (R||S). OpenSSL expects DER format. Use this Elixir one-liner to convert:

  
  
    
elixir -e '
sig_b64 = File.read!("jws_original.txt") |> String.trim() |> String.split(".") |> Enum.at(2)
sig_raw = Base.url_decode64!(sig_b64, padding: false)
<<r::binary-size(32), s::binary-size(32)>> = sig_raw

# Encode as DER
r_int = :binary.decode_unsigned(r, :big)
s_int = :binary.decode_unsigned(s, :big)

encode_der_int = fn val ->
  bytes = :binary.encode_unsigned(val, :big)
  bytes = if :binary.first(bytes) >= 0x80, do: <<0x00, bytes::binary>>, else: bytes
  <<0x02, byte_size(bytes), bytes::binary>>
end

r_der = encode_der_int.(r_int)
s_der = encode_der_int.(s_int)
seq = r_der <> s_der
der = <<0x30, byte_size(seq), seq::binary>>

File.write!("signature.der", der)
IO.puts("✓ Signature converted to DER format")
'

    
  

Step 3: Verify with OpenSSL

  
  
    
openssl dgst -sha256 -verify public_key.pem -signature signature.der signing_input.txt

    
  

If the signature is valid, OpenSSL outputs:

  
  
    
Verified OK

    
  

What This Proves

If OpenSSL outputs Verified OK , this mathematically proves:

  1. The signature was created by the holder of the private key corresponding to public_key.pem
  2. The signing input ( {header}.{payload} ) has not been modified since signing
  3. The math is independently verifiable: this isn't dependent on your code or trust in your systems

This verification can be performed by anyone with the verification package from Step 4, requiring only standard tools (Elixir and OpenSSL). No need to trust your systems or code, the math speaks for itself.

The complete OpenSSL verification protocol is documented in AUDIT.md. The signature conversion logic is in convert_signature_to_der.exs. This enables independent third-party verification without relying on your codebase or systems: the cryptographic proof stands on its own using standard tools.

Regulatory Compliance: Retention and Immutability

Different jurisdictions have different requirements, but common patterns emerge:

Retention Periods

7+ years for financial data is typical:

  • PCI DSS: 7 years
  • SOX (Sarbanes-Oxley): 7 years
  • EU financial regulations: 5-10 years depending on record type
  • US federal regulations: 7 years

Immutability Requirements

Audit logs must be tamper-evident. You need to prove logs haven't been modified. Options to consider include append-only storage, cryptographic hash chains, and external audit trail services. All of these options make tampering detectable.

Just remeber that your audit trail is useless if you can't verify signatures years later: test this rigorously!

What Regulators Actually Look For

Financial regulators and auditors consistently examine these dimensions during audits:

1. Can you retrieve the original signed instruction?

  • Show them the JWS string from your audit log
  • Demonstrate it hasn't been modified (hash chains, immutable storage)

2. Can you independently verify the signature?

  • Provide the issuer's public key
  • Walk through verification using a standard library (OpenSSL)
  • Show the signature is mathematically valid

3. Do you have complete metadata?

  • When was it received?
  • Who sent it (issuer ID)?
  • What IP address?
  • When was it verified?
  • What algorithm and key were used?

4. Can you detect tampering?

  • Show that modifying the payload breaks the signature
  • Demonstrate your immutability controls (append-only logs, hash chains)

5. How long do you retain records?

  • Document your retention policy
  • Show automated enforcement
  • Demonstrate compliance with applicable regulations

6. Can you respond to disputes?

  • Walk through your dispute resolution process
  • Show example evidence packages
  • Demonstrate you can re-verify old signatures

Wrapping Up

Production systems generate data constantly. Audit trails are what separate data from evidence. The engineering challenge isn't storing records, it's preserving cryptographic proof in a form that survives system migrations, regulatory audits, legal discovery, and disputes years in the future. This requires balancing storage costs, query performance, retention policies, and cryptographic integrity. Teams that understand this distinction build systems that win disputes. Teams that don't, lose them.

You've now built a complete JWS non-repudiation system: from signing and verification through key distribution to long-term proof preservation. But how do you test it thoroughly? How do you migrate existing systems? What do you do when things go wrong at 3 AM? The final post covers the operational reality: testing strategies, migration patterns, and troubleshooting the most common production issues.

 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.