Audit Trails That Win Disputes
The "Forever" Proof
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:
- re-verification from audit logs with re_verify/1
- evidence packaging with generate_verification_package/2
- DER conversion script required for independent OpenSSL verification here
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:
- The signature was created by the holder of the private key
corresponding to
public_key.pem - The signing input (
{header}.{payload}) has not been modified since signing - 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.