March 2026 · ·

ERC-8128 Gated API: Replace API Keys with Wallet Signatures

API keys are a human bottleneck for agents. Someone has to register, generate a key, store it, rotate it. ERC-8128 replaces all of that with wallet signatures. No registration, no tokens, just sign and call.

I built a proof-of-concept gateway that uses ERC-8128 for authentication instead of API keys. Any Ethereum wallet can call the API immediately. The server recovers the signer address from the request signature and uses that as the identity. Code is at github.com/wgopar/erc-8128-gated-api.


The Problem

Agents need to call APIs autonomously. But API keys require a human to sign up, generate credentials, and inject them into the agent's environment. OAuth is even worse. Redirect flows, token refresh, consent screens. None of that works when an agent needs to call a new service it just discovered.

Wallets are already agent-native. An agent can generate a key pair on its own, sign messages, and prove its identity without any external registration step. ERC-8128 takes this and turns it into a standard HTTP authentication scheme.


How ERC-8128 Works

ERC-8128 combines two existing standards:

  • RFC 9421 (HTTP Message Signatures). Defines which parts of the request get signed and how the signature metadata is structured.
  • ERC-191 (Ethereum Signed Data). The actual signature scheme, using personal_sign.

Every authenticated request carries three headers:

Header Purpose
signature-input Declares which request components are covered, plus metadata (nonce, timestamp, key ID = wallet address)
signature The ERC-191 signature over the canonicalized components
x-erc8128-nonce A unique nonce for replay protection

On the server side, the flow is: parse the headers, check the nonce hasn't been used, reconstruct the canonical string from the request, then use ecrecover to derive the signer's Ethereum address from the signature. If the recovered address matches the keyid in signature-input, the request is authentic.


The Gateway

The gateway is a Hono app with middleware-based auth. Public routes like the service catalog don't require signatures. Authenticated routes go through the ERC-8128 middleware, which verifies the signature and sets the signer address on the request context.

Here's the route setup:

const nonceStore = createNonceStore();

const app = new Hono();

// Public routes (no auth)
app.route("/", publicRoutes);

// Authenticated routes, middleware applied to all sub-routes
const authed = new Hono<Erc8128Env>();
authed.use("/*", erc8128Auth(nonceStore));
authed.route("/", createGatewayRoutes());
authed.route("/", createAccountRoutes());

app.route("/", authed);

And the middleware itself:

export function erc8128Auth(nonceStore: NonceStore): MiddlewareHandler<Erc8128Env> {
  return async (c, next) => {
    const verify: VerifyMessageFn = ({ address, message, signature }) =>
      verifyMessage({ address, message, signature });

    const result = await verifyRequest({
      request: c.req.raw,
      verifyMessage: verify,
      nonceStore,
    });

    if (result.ok) {
      c.set("signer", result.address);
      await next();
    } else {
      return c.json({ error: result.reason, detail: result.detail }, 401);
    }
  };
}

The verifyRequest function from @slicekit/erc8128 handles all the RFC 9421 parsing and ERC-191 recovery. The middleware just wires it into Hono's context so downstream handlers can read c.get("signer") to know who's calling.


Replay Protection

Every signed request includes a unique nonce. The server tracks seen nonces in memory with a 60-second TTL. If a nonce has been used before, the request is rejected as a replay.

export function createNonceStore(
  cleanupIntervalMs = 60_000,
): InMemoryNonceStore {
  const map = new Map<string, number>();

  const timer = setInterval(() => {
    const now = Date.now();
    for (const [key, expiry] of map) {
      if (expiry <= now) map.delete(key);
    }
  }, cleanupIntervalMs);

  if (timer.unref) timer.unref();

  return {
    async consume(key: string, ttlSeconds: number): Promise<boolean> {
      const now = Date.now();
      const existing = map.get(key);

      // If key exists and hasn't expired, it's a replay
      if (existing !== undefined && existing > now) {
        return false;
      }

      map.set(key, now + ttlSeconds * 1000);
      return true;
    },

    destroy() {
      clearInterval(timer);
      map.clear();
    },
  };
}

The consume method returns false if the nonce was already seen (replay), true if it's fresh. The cleanup interval sweeps expired entries so the map doesn't grow unbounded. For production you'd swap this with Redis or a similar store, but for a PoC it works.


Client Signing

On the client side, an agent signs a request using @slicekit/erc8128 and viem. The library takes a standard Request object and returns a new one with the three auth headers attached.

import { privateKeyToAccount } from "viem/accounts";
import { signRequest, type EthHttpSigner } from "@slicekit/erc8128";

const account = privateKeyToAccount(PRIVATE_KEY);

const signer: EthHttpSigner = {
  chainId: 1,
  address: account.address,
  signMessage: (msg) => account.signMessage({ message: { raw: msg } }),
};

// GET request, just pass the signer
const req = new Request("http://localhost:3000/account");
const signed = await signRequest(req, signer);
const res = await fetch(signed);

// POST with body, pass RequestInit as second arg
const echoReq = await signRequest(
  new Request("http://localhost:3000/v1/echo"),
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message: "hello from my agent" }),
  },
  signer,
);
const echoRes = await fetch(echoReq);

Each call to signRequest generates a fresh nonce, so you can't reuse a signed request. The signature covers the method, URL, headers, and a digest of the body. If anything is tampered with after signing, verification fails.


Tech Stack

  • Runtime: Node.js 18+
  • Framework: Hono v4.12
  • Auth: @slicekit/erc8128 v0.2.0
  • Ethereum: viem v2.46
  • Language: TypeScript
  • Tests: vitest

Code

The full source is at github.com/wgopar/erc-8128-gated-api. Clone it, run npm install, and npx tsx src/index.ts to start the gateway locally.

git clone https://github.com/wgopar/erc-8128-gated-api.git
cd erc-8128-gated-api
npm install
npx tsx src/index.ts