Wallet Signature Authentication
By AnchorFactory ·
Every Keeta account can sign arbitrary data with its private key. This lets your anchor verify that a request genuinely comes from a specific wallet. No API keys, no OAuth tokens — the blockchain account IS the identity.
The Two Patterns
There are two ways to use signatures, depending on what you're verifying:
Pattern | Use Case | SDK |
|---|---|---|
SignData / VerifySignedData | Structured field signing (anchors, quotes) |
|
account.sign / account.verify | Raw message signing (login, proof of ownership) |
|
Pattern 1: SignData (Structured Fields)
Best for anchor-to-anchor and client-to-anchor communication where you're signing structured data fields.
Client Side (Signing)
const { SignData } = await import("@keetanetwork/anchor/lib/utils/signing.js");
// Sign an array of string fields
const signed = await SignData(account, [
"field1_value",
"field2_value",
"field3_value"
]);
// signed = { nonce: "hex...", timestamp: "epoch_ms", signature: "hex..." }Server Side (Verifying)
const { VerifySignedData } = await import("@keetanetwork/anchor/lib/utils/signing.js");
const KeetaNet = require("@keetanetwork/keetanet-client");
// Reconstruct account from address (public key only — no private key needed)
const account = KeetaNet.lib.Account.fromPublicKeyString(address);
// Verify — fields must match exactly what was signed
const valid = await VerifySignedData(account, [
"field1_value",
"field2_value",
"field3_value"
], signed);
if (!valid) throw new Error("Invalid signature");Express Middleware
const { VerifySignedData } = await import("@keetanetwork/anchor/lib/utils/signing.js");
const KeetaNet = require("@keetanetwork/keetanet-client");
export async function requireWalletAuth(req, res, next) {
try {
const { account: address, fields, signed } = req.body;
if (!address || !fields || !signed) {
return res.status(401).json({ error: "Missing signature" });
}
const account = KeetaNet.lib.Account.fromPublicKeyString(address);
const valid = await VerifySignedData(account, fields, signed);
if (!valid) {
return res.status(401).json({ error: "Invalid signature" });
}
// Check freshness — reject signatures older than 60 seconds
const age = Date.now() - parseInt(signed.timestamp);
if (age > 60_000) {
return res.status(401).json({ error: "Signature expired" });
}
req.walletAddress = address;
next();
} catch (err) {
res.status(401).json({ error: "Authentication failed" });
}
}Usage:
app.post("/protected-endpoint", requireWalletAuth, async (req, res) => {
// req.walletAddress is the verified address
res.json({ ok: true, address: req.walletAddress });
});Pattern 2: Raw Message Signing
Best for simple proof-of-ownership scenarios like login challenges or attestations. This is what the Alpaca Wallet extension exposes via window.alpaca.signMessage().
Client Side (Browser Extension)
// Extension signs with the wallet's private key
const { signature, address } = await window.alpaca.signMessage("Login to MyDapp");
// signature: hex-encoded detached signature
// address: signer's keeta_ address
// Send to your backend
await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address, message: "Login to MyDapp", signature }),
});Server Side (Verifying)
const KeetaNet = require("@keetanetwork/keetanet-client");
function verifyMessage(address, message, signatureHex) {
const account = KeetaNet.lib.Account.fromPublicKeyString(address);
const msgBytes = new TextEncoder().encode(message);
const sigBytes = Buffer.from(signatureHex, "hex");
return account.verify(msgBytes.buffer, sigBytes);
}
// Express route
app.post("/api/login", (req, res) => {
const { address, message, signature } = req.body;
// Verify the message is what you expected
if (message !== "Login to MyDapp") {
return res.status(400).json({ error: "Unexpected message" });
}
const valid = verifyMessage(address, message, signature);
if (!valid) {
return res.status(401).json({ error: "Invalid signature" });
}
// Address is verified — create a session, issue a JWT, etc.
res.json({ ok: true, address });
});Login Challenge Flow
For production login, use a challenge-response pattern to prevent replay attacks:
1. Client: GET /api/challenge
2. Server: Returns a random nonce + timestamp
3. Client: Signs "LOGIN:{nonce}:{timestamp}" with wallet
4. Server: Verifies signature + checks nonce hasn't been used + checks timestamp freshness
5. Server: Issues session token// Server
const crypto = require("crypto");
const usedNonces = new Set();
app.get("/api/challenge", (req, res) => {
const nonce = crypto.randomBytes(16).toString("hex");
const timestamp = Date.now().toString();
res.json({ nonce, timestamp });
});
app.post("/api/login", (req, res) => {
const { address, nonce, timestamp, signature } = req.body;
// Prevent replay
if (usedNonces.has(nonce)) return res.status(401).json({ error: "Nonce already used" });
// Check freshness (60 seconds)
if (Date.now() - parseInt(timestamp) > 60_000) {
return res.status(401).json({ error: "Challenge expired" });
}
const message = `LOGIN:${nonce}:${timestamp}`;
const valid = verifyMessage(address, message, signature);
if (!valid) return res.status(401).json({ error: "Invalid signature" });
usedNonces.add(nonce);
// Issue JWT or session...
res.json({ ok: true, address });
});Real-World Examples
Quote Signing (FX Anchors)
FX anchors sign quotes so users can't forge prices:
// Anchor signs the quote
const signed = await SignData(signerAccount, [
quote.from,
quote.to,
quote.amount.toString(),
quote.convertedAmount.toString(),
quote.fee.toString(),
]);
// Later, verify before executing
const valid = await VerifySignedData(signerAccount, [
request.quote.from,
request.quote.to,
request.quote.amount.toString(),
request.quote.convertedAmount.toString(),
request.quote.fee.toString(),
], request.quote.signed);Collective Membership (Proof of Wallet Ownership)
// Client: sign a prefixed message
const message = "MY_DAPP_" + address;
const { signature } = await window.alpaca.signMessage(message);
// Server: verify ownership
const expectedMessage = "MY_DAPP_" + address;
if (message !== expectedMessage) throw new Error("Invalid message format");
const valid = verifyMessage(address, message, signature);Security Checklist
Always verify on the server — never trust client-side verification alone
Check message content — verify the signed message matches what you expected, not just that the signature is valid
Add timestamps — prevent replay attacks by rejecting old signatures
Use nonces for login — one-time challenges prevent signature reuse
Prefix your messages — use a unique prefix per use case (e.g.,
"LOGIN:","ATTEST:") to prevent cross-protocol signature reuseNever log signatures — they're proof of intent, treat them like auth tokens
Next Steps
Build an FX Anchor — see quote signing in a full anchor implementation
Webapp Integration — use
window.alpaca.signMessage()from a React appCommon Pitfalls — the
account.sign()return type gotcha