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
:okInstall 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()
value7Retention 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_verifiedReuse 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_usedA 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_rejectedKeep 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
endThe 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_usedSee 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)
:okWhere to next
- Idempotency — safe retries with stored-result replay.
- External effects and recovery — committing side effects safely.
- One-time nonces guide.
- Transaction-owned admission — spend nonces inside a host-owned transaction.