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)

@keetanetwork/anchor

account.sign / account.verify

Raw message signing (login, proof of ownership)

@keetanetwork/keetanet-client

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 reuse

  • Never log signatures — they're proof of intent, treat them like auth tokens

Next Steps

More in Identity & Auth · All documentation