Ed25519 Signatures Explained: How SignedApproval Creates Unforgeable Proof
When someone asks “how do you know a human approved this?” the answer should be mathematical, not organizational. Not “we checked the audit log” or “the webhook returned 200.” The answer should be: “here is a signature that is computationally impossible to forge without the private key, and here is the public key to verify it yourself.”
That is exactly what SignedApproval produces. This post explains how, from the ground up.
Why Ed25519
Ed25519 is an elliptic curve signature scheme designed by Daniel J. Bernstein. We chose it for SignedApproval for specific, practical reasons:
- Fast.Signing and verification take microseconds. An approval decision adds negligible latency to your agent’s workflow.
- Small. Public keys are 32 bytes. Signatures are 64 bytes. They fit in HTTP headers, JWTs, database columns, QR codes.
- Deterministic.Unlike ECDSA, Ed25519 doesn’t require a random nonce during signing. Same input always produces the same signature. This eliminates an entire class of implementation bugs (recall the 2010 PlayStation 3 private key leak caused by a reused ECDSA nonce).
- Widely supported. Native in Node.js, Python, Go, Rust, and every major language. No external crypto libraries needed.
- No shared secrets. Unlike HMAC, the verifier only needs the public key. You can publish your public key to the world without compromising security.
Asymmetric vs. symmetric: why it matters
Many systems use HMAC (Hash-based Message Authentication Code) for message signing. HMAC uses a shared secret— both the signer and verifier need the same key. This creates a fundamental problem for approval verification:
| Property | HMAC (shared secret) | Ed25519 (asymmetric) |
|---|---|---|
| Who can forge? | Anyone with the secret key | Only the private key holder |
| Verifier trust | Verifier could forge signatures | Verifier cannot forge signatures |
| Key distribution | Secret must be shared securely | Public key can be published openly |
| Third-party verification | Requires sharing the secret | Anyone can verify with public key |
With HMAC, giving someone the ability to verify approvals also gives them the ability to forge approvals. With Ed25519, verification and signing are completely separated. Your auditor, your compliance team, your customers — all can verify approvals without any ability to create new ones.
The signing flow
When an approver authenticates and approves an action, SignedApproval constructs a canonical payload, signs it, and returns the result. Here is exactly what happens:
Step 1: Construct the canonical payload
The payload is a JSON object with a fixed set of fields in a deterministic order. Every field is included every time — no optional fields that could create ambiguity:
{
"v": 1, // payload version
"rid": "a1b2c3d4-...", // approval request ID
"did": "e5f6g7h8-...", // decision ID
"approver": "8f14e45f...", // sha256(user_id)[:16]
"action": "3c9909af...", // sha256(action_text)[:16]
"decision": "approved", // "approved" or "rejected"
"method": "passkey", // "passkey", "totp", or "biometric"
"ts": 1709000000, // Unix timestamp of decision
"exp": 1709086400, // expiration timestamp
"nonce": "a4f2e8c1d9b3..." // random 32-byte hex
}Notice that the approver identity and action text are hashed before inclusion. This means the signed payload does not contain PII or sensitive action details in cleartext. A verifier can still confirm that a specific user approved a specificaction by computing the same hashes — but someone who intercepts the signature alone cannot extract the original values.
Step 2: Serialize and sign
The payload is serialized as a JSON string with keys in the exact order shown above (no whitespace, no reordering). This byte string is then signed with the approver’s Ed25519 private key:
import crypto from "crypto";
// Private key is decrypted from AES-256-GCM at rest
const privateKey = decryptSigningKey(encryptedKey, ENCRYPTION_KEY);
// Canonical JSON — deterministic serialization
const message = Buffer.from(JSON.stringify(payload));
// Ed25519 signature (64 bytes)
const signature = crypto.sign(null, message, privateKey);
// Encode as base64url for transport
const signatureB64 = signature.toString("base64url");Step 3: Return the credential
The caller receives the complete credential: payload, signature, and the public key needed for verification:
{
"id": "a1b2c3d4-...",
"status": "approved",
"decision": {
"payload": { ... }, // the canonical payload above
"signature": "xK9m2p...", // base64url Ed25519 signature
"publicKey": "MCow..." // base64url Ed25519 public key
}
}Verification: anyone, anywhere, offline
Verification is a single function call in any language. No API call to SignedApproval, no network access, no token exchange. Just math.
Node.js
import crypto from "crypto";
function verifyApproval(payload, signatureB64, publicKeyB64) {
const message = Buffer.from(JSON.stringify(payload));
const signature = Buffer.from(signatureB64, "base64url");
const publicKey = crypto.createPublicKey({
key: Buffer.from(publicKeyB64, "base64url"),
format: "der",
type: "spki",
});
const valid = crypto.verify(null, message, publicKey, signature);
if (!valid) throw new Error("Invalid signature");
// Check expiration
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error("Approval expired");
}
return true;
}Python
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import load_der_public_key
import base64, json, time
def verify_approval(payload: dict, signature_b64: str, public_key_b64: str) -> bool:
message = json.dumps(payload, separators=(",", ":")).encode()
signature = base64.urlsafe_b64decode(signature_b64 + "==")
public_key = load_der_public_key(
base64.urlsafe_b64decode(public_key_b64 + "==")
)
# Raises InvalidSignature if verification fails
public_key.verify(signature, message)
# Check expiration
if payload["exp"] < int(time.time()):
raise ValueError("Approval expired")
return TrueYou can also use the public verification endpoint at GET /api/v1/approvals/{id}/verify if you prefer not to handle the cryptography yourself. But the point is that you can verify independently. The proof stands on its own.
Key management and rotation
Each approver gets their own Ed25519 key pair, generated on first approval. The private key is encrypted at rest using AES-256-GCM with a server-side encryption key. The format in the database is iv:authTag:ciphertext, all base64url-encoded.
Key rotation is straightforward: generate a new key pair, mark the old one as inactive, and start signing with the new key. Old signatures remain verifiable because the public key is included in every credential. The verification endpoint also serves historical public keys for old approvals.
What the signature proves
A valid SignedApproval signature proves exactly five things:
- A specific human (identified by hashed user ID) approved the action
- They approved a specific action (identified by hashed action text)
- They approved it at a specific time (Unix timestamp)
- They authenticated with a specific method (passkey, TOTP, or biometric)
- The approval has not been modified since it was signed
It does not prove that the human read the action carefully, or that they understood all implications. Those are UX problems, not cryptographic ones. But it does provide a foundation of mathematical certainty that no amount of Slack messages or email links can match. And in a world where AI agents are making increasingly consequential decisions, that foundation matters.
Ready to see this in action? Check out our integration guide or browse the API documentation.
Related posts
Ready to add cryptographic approvals to your agents?
200 free approvals per month. No credit card required.
Get your API key