One-time nonces — at-most-once admission

Copy Markdown View Source
Mix.install(
  [
    {:ash_onetime, "~> 1.6.0"},
    {:ash_postgres, "~> 2.13"}
  ],
  consolidate_protocols: false
)

What this notebook covers

A one-time nonce protects a request against replay. The first spend of a verified proof succeeds; every reuse is rejected. There is no stored result to replay — only a fail-closed spend. Use this when an action must run at most once even if the client retries (single-use redemptions, anti-replay of a captured request).

This notebook walks through the spend and the reuse rejection — against a real PostgreSQL.

Requires Elixir 1.20+, Ash 3.34.6+, and PostgreSQL 18 reachable at DATABASE_URL. Use a development database where you can create and remove schemas. This notebook signs and verifies real Ed25519 proofs; its simple proof format illustrates nonce admission and is not a DPoP implementation.

Connect to PostgreSQL

database_url = System.fetch_env!("DATABASE_URL")
Logger.configure(level: :warning)

:ok = Application.put_env(:ash, :disable_async?, true)
# Ash >= 3.33 requires an explicit string-length counting mode; codepoints
# matches how the SQL data layer counts (EEF-CVE-2026-82752 remediation).
:ok = Application.put_env(:ash, :default_string_length_count, :codepoints)
:ok = Application.put_env(:ash_onetime_demo, AshOnetimeDemo.Repo, url: database_url, pool_size: 10)

Define the Repo, verifier, and resource

defmodule AshOnetimeDemo.Repo do
  use AshPostgres.Repo, otp_app: :ash_onetime_demo

  @impl AshPostgres.Repo
  def installed_extensions, do: ["ash-functions"]

  @impl AshPostgres.Repo
  def min_pg_version, do: %Version{major: 18, minor: 0, patch: 0}
end

{:ok, _} = AshOnetimeDemo.Repo.start_link()
# Only the public key enters server-side verification configuration. The signing
# key remains with the issuer in this notebook's binding.
{public_key, private_key} = :crypto.generate_key(:eddsa, :ed25519)
:ok = Application.put_env(:ash_onetime_demo, :proof_public_key, public_key)

sign_proof = fn nonce ->
  payload = Jason.encode!(%{nonce: nonce, issued_at: DateTime.to_unix(DateTime.utc_now())})
  signature = :crypto.sign(:eddsa, :none, payload, [private_key, :ed25519])
  Base.url_encode64(payload, padding: false) <> "." <> Base.url_encode64(signature, padding: false)
end

defmodule AshOnetimeDemo.ProofVerifier do
  @behaviour AshOnetime.Verifier

  @impl true
  def verify(proof, _context) when is_binary(proof) do
    public_key = Application.fetch_env!(:ash_onetime_demo, :proof_public_key)

    with [encoded_payload, encoded_signature] <- String.split(proof, "."),
         {:ok, payload} <- Base.url_decode64(encoded_payload, padding: false),
         {:ok, signature} <- Base.url_decode64(encoded_signature, padding: false),
         true <- :crypto.verify(:eddsa, :none, payload, signature, [public_key, :ed25519]),
         {:ok, %{"nonce" => nonce, "issued_at" => issued_at}} <- Jason.decode(payload),
         true <- is_binary(nonce) and is_integer(issued_at),
         {:ok, timestamp} <- DateTime.from_unix(issued_at) do
      AshOnetime.Verified.new(key: nonce, issued_at: timestamp, verifier_id: "notebook-issuer")
    else
      _ -> {:error, :invalid_signature}
    end
  end

  @impl true
  def algorithm, do: :ed25519
  @impl true
  def trust_model, do: :separated
end

defmodule AshOnetimeDemo.FailRun do
  use Ash.Resource.Actions.Implementation

  @impl true
  def run(_input, _opts, _context),
    do: {:error, RuntimeError.exception("the action body failed")}
end

# A protected generic action's `run` must be a module-based implementation.
defmodule AshOnetimeDemo.RedeemRun do
  use Ash.Resource.Actions.Implementation

  @impl true
  def run(input, _opts, _context), do: {:ok, Ash.ActionInput.get_argument(input, :value)}
end

defmodule AshOnetimeDemo.Domain do
  use Ash.Domain, validate_config_inclusion?: false

  resources do
    resource AshOnetimeDemo.Redeemable
  end
end

defmodule AshOnetimeDemo.Redeemable do
  use Ash.Resource,
    domain: AshOnetimeDemo.Domain,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshOnetime.Resource]

  postgres do
    table "demo_redeemables"
    repo AshOnetimeDemo.Repo
  end

  multitenancy do
    strategy :context
  end

  attributes do
    uuid_primary_key :id
  end

  actions do
    defaults [:read]

    action :redeem, :integer do
      transaction? true
      argument :value, :integer, allow_nil?: false
      argument :proof, :string, allow_nil?: false
      run {AshOnetimeDemo.RedeemRun, []}
    end
  end

  onetime do
    protect :redeem do
      strategy :one_time_nonce
      scope([{:static, "redeem"}])
      key({:verified, :proof, AshOnetimeDemo.ProofVerifier})
      window(max_age: {5, :minute}, clock_skew: {5, :second}, retain_for: {664, :second})
    end
  end
end

:ok

Install the admission store

alias AshOnetimeDemo.Repo
alias Ecto.Adapters.SQL

unique = Ecto.UUID.generate() |> String.replace("-", "")
schema = "ash_onetime_demo_#{unique}"
temp = Path.join(System.tmp_dir!(), "ash_onetime_demo_#{unique}")
File.mkdir_p!(temp)

{schema, temp}

source =
  Mix.Tasks.AshOnetime.Gen.Migrations.render(Repo,
    hash_partitions: nil,
    partition_start: Date.utc_today() |> Date.beginning_of_month()
  )

timestamp = "20260101000000"
path = Path.join(temp, "#{timestamp}_install_ash_onetime.exs")
File.write!(path, source)
[{module, _}] = Code.compile_file(path)
version = String.to_integer(timestamp)

SQL.query!(Repo, ~s{CREATE SCHEMA "#{schema}"}, [])
:ok = Ecto.Migrator.up(Repo, version, module, prefix: schema, log: false)

# The business resource table (the redeemable has only an id; the nonce action is a generic
# action with no row, but the resource still needs its table for read defaults).
SQL.query!(
  Repo,
  """
  CREATE TABLE IF NOT EXISTS "#{schema}"."demo_redeemables" (
    id uuid PRIMARY KEY
  )
  """,
  []
)

{schema, version, module}

Spend once

The first :redeem spends the proof and runs the action body.

proof = sign_proof.("proof-once")

{:ok, value} =
  AshOnetimeDemo.Redeemable
  |> Ash.ActionInput.for_action(:redeem, %{value: 7, proof: proof})
  |> Ash.ActionInput.set_tenant(schema)
  |> Ash.run_action()

value
7

Retention stays separate from acceptance

The resource accepts proofs for five minutes (plus five seconds of skew), but keeps claims for 664 seconds plus skew and the cleanup margin. The stored deadline records that choice:

%{rows: [[issued_at, retain_until]]} =
  SQL.query!(Repo, ~s{SELECT issued_at, retain_until FROM "#{schema}".ash_onetime_nonce_claims}, [])

true = retain_until == AshOnetime.Window.cleanup_after(issued_at, 664, 5)
:retention_verified
:retention_verified

Reuse is rejected

Reusing the same proof is rejected as :nonce_already_used. The action body does not run.

{:error, error} =
  AshOnetimeDemo.Redeemable
  |> Ash.ActionInput.for_action(:redeem, %{value: 7, proof: proof})
  |> Ash.ActionInput.set_tenant(schema)
  |> Ash.run_action()

AshOnetime.Error.code(error)
:nonce_already_used

A changed proof is not trusted

Changing a signed payload without issuing a new signature fails verification. It cannot supply trusted nonce or timestamp facts:

[encoded_payload, encoded_signature] = String.split(proof, ".")
{:ok, payload} = Base.url_decode64(encoded_payload, padding: false)
changed_payload = String.replace(payload, "proof-once", "other-nonce")
tampered = Base.url_encode64(changed_payload, padding: false) <> "." <> encoded_signature

{:error, :invalid_signature} = AshOnetimeDemo.ProofVerifier.verify(tampered, %{})
:tampered_proof_rejected
:tampered_proof_rejected

Keep the spend after body failure (commit: :independent)

By default the nonce spend commits inside the action's transaction, so an action-body failure rolls the spend back — correct when a retry will bear a fresh proof. For RFC 9449 (DPoP) §11.1 replay protection, declare commit: :independent so the claim commits in its own transaction before the body runs. A DPoP verifier must separately validate the complete RFC proof; this cell demonstrates the transaction rule with the signed nonce format above. A body failure leaves that nonce spent for the retention window:

defmodule AshOnetimeDemo.FailureDomain do
  use Ash.Domain, validate_config_inclusion?: false

  resources do
    resource AshOnetimeDemo.RedeemableFail
  end
end

defmodule AshOnetimeDemo.RedeemableFail do
  use Ash.Resource,
    domain: AshOnetimeDemo.FailureDomain,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshOnetime.Resource]

  postgres do
    table "demo_redeemable_fail"
    repo AshOnetimeDemo.Repo
  end

  multitenancy do
    strategy :context
  end

  attributes do
    uuid_primary_key :id
  end

  actions do
    defaults [:read]

    action :redeem_fail, :integer do
      transaction? true
      argument :value, :integer, allow_nil?: false
      argument :proof, :string, allow_nil?: false
      run {AshOnetimeDemo.FailRun, []}
    end
  end

  onetime do
    protect :redeem_fail do
      strategy :one_time_nonce
      scope([{:static, "redeem_fail"}])
      key({:verified, :proof, AshOnetimeDemo.ProofVerifier})
      window(max_age: {5, :minute}, clock_skew: {5, :second}, retain_for: {664, :second})
      commit :independent
    end
  end
end

The body fails, but the spend survived — a retry with the same proof is rejected:

failing_proof = sign_proof.("independent-proof")

{:error, failure} =
  AshOnetimeDemo.RedeemableFail
  |> Ash.ActionInput.for_action(:redeem_fail, %{value: 1, proof: failing_proof})
  |> Ash.ActionInput.set_tenant(schema)
  |> Ash.run_action()

true = Exception.message(failure) =~ "the action body failed"

# Both the action-owned and independently committed claims use the declared retention.
%{rows: retained_claims} =
  SQL.query!(Repo, ~s{SELECT issued_at, retain_until FROM "#{schema}".ash_onetime_nonce_claims}, [])
2 = length(retained_claims)
true = Enum.all?(retained_claims, fn [issued, deadline] ->
  deadline == AshOnetime.Window.cleanup_after(issued, 664, 5)
end)


{:error, retry} =
  AshOnetimeDemo.RedeemableFail
  |> Ash.ActionInput.for_action(:redeem_fail, %{value: 1, proof: failing_proof})
  |> Ash.ActionInput.set_tenant(schema)
  |> Ash.run_action()

AshOnetime.Error.code(retry)
:nonce_already_used

See ADR-0003 (Independent-commit nonce) for the contract and the operations guide for pool-sizing notes.

Clean up

:ok = Ecto.Migrator.down(Repo, version, module, prefix: schema, log: false)
SQL.query!(Repo, ~s{DROP SCHEMA IF EXISTS "#{schema}" CASCADE}, [])
File.rm_rf!(temp)
:ok

Where to next