Documentation

Article

API authentication

Every request, signed. Agents sign each HTTP request with their own key, and your API checks it against keys cached from messh. There are no bearer tokens to leak or rotate.

  1. mIdentity messh publish --name build-bot
  2. Sign client.fetch()client.post()client.Do()client.send()client.send() signs locally; the key stays on the machine
  3. Verify messhAuth()MesshAuthMiddlewarev.Middleware()verifier.verify()MesshHttpFilter checks signature, time, nonce and key
  4. Authorize Your code decides what the agent may do

messh isn’t in the request path. The agent signs with a key that never leaves its machine, and your API checks the signature against public keys it has cached from messh, so verification is local. The format is Web Bot Auth: RFC 9421 HTTP Message Signatures with Ed25519.

Identitymukul

A messh username. An agent is a named key under it: messh publish --name build-bot.

Key IDBEGCMYMP…555R4

The key’s RFC 7638 thumbprint, listed at messh.io/mukul/.well-known/http-message-signatures-directory.

Trustmukul

Who your API accepts: listed usernames, or * for any valid messh signer.

Install the SDK#

Five steps. Commands and code follow the language picked in the bar above.

  1. 1

    Publish the agent’s key

    An agent signs as you, with a key of its own. Generate an ssh-ed25519 key on the agent’s machine and publish it under a name; the private half never leaves that machine. APIs find it in your key directory, messh.io/<you>/.well-known/http-message-signatures-directory.

    Terminal
    $ ssh-keygen -t ed25519 -f ~/.ssh/build-bot -N ""
    $ messh login
    $ messh publish --key ~/.ssh/build-bot.pub --name build-bot --agent build-bot
    signing ownership challenge with /home/ci/.ssh/build-bot…
    ✓ ssh-ed25519 SHA256:dPdarb/VmzDFPpaZ1o3TI4aJZtk0THYLYXgBwklkpIQ (build-bot)
    ✓ Live at https://messh.io/mukul
  2. 2

    Add the SDK to the agent

    The client signs every request it sends. Nothing else in your agent changes.

    Terminal
    $ npm install @messh/sdk     # or: pnpm add @messh/sdk · bun add @messh/sdk
    Terminal
    $ pip install messh-sdk      # import messh · extras: [httpx], [requests], [fastapi]
    Terminal
    $ go get messh.io/go/[email protected]
    Terminal
    $ cargo add messh            # features: fetch (default), http
    implementation("io.messh:messh-sdk:0.1.0")
    <dependency>
      <groupId>io.messh</groupId>
      <artifactId>messh-sdk</artifactId>
      <version>0.1.0</version>
    </dependency>

    Node 20+. ESM, types included, no dependencies: signing uses node:crypto.Python 3.9+. One dependency, cryptography; the httpx, requests and Starlette integrations are extras.Go 1.22+. Standard library only.Rust 1.85+. ed25519-dalek; ureq fetches key directories (feature fetch, on by default). Feature http adds http::Request support.Java 17+. JDK only: the built-in Ed25519 provider and java.net.http.

  3. 3

    Add the verifier to your API

    It’s in the same package. It checks every incoming request before your handlers run, with public keys fetched from messh and cached, so verification stays local.

    Terminal
    # same package: import { messhAuth } from "@messh/sdk/verify"
    $ npm install @messh/sdk
    Terminal
    # same package: from messh.verify import MesshAuthMiddleware
    $ pip install "messh-sdk[fastapi]"
    Terminal
    # same module: import "messh.io/go/messh/verify"
    $ go get messh.io/go/[email protected]
    Terminal
    # same crate: use messh::verify::Verifier
    $ cargo add messh --features http
    implementation("io.messh:messh-sdk:0.1.0")  // same artifact: io.messh.verify
    <dependency>
      <groupId>io.messh</groupId>
      <artifactId>messh-sdk</artifactId>
      <version>0.1.0</version>
    </dependency>
  4. 4

    Choose who to trust

    The one required setting: the messh usernames your API accepts, or "*" for anyone with a valid messh signature. Valid signatures from anyone else get 403 untrusted.

    server.tsmain.pymain.gomain.rsDeployments.java
    // the usernames your API accepts; ["*"] accepts any valid messh signer
    app.use("/api", messhAuth({ trust: ["mukul", "acme-ci"] }))
    # the usernames your API accepts; ["*"] accepts any valid messh signer
    app.add_middleware(MesshAuthMiddleware, trust=["mukul", "acme-ci"])
    // the usernames your API accepts; Trust("*") accepts any valid messh signer
    v, err := verify.New(verify.Trust("mukul", "acme-ci"))
    // the usernames your API accepts; ["*"] accepts any valid messh signer
    let verifier = Verifier::new(VerifyConfig::trust(["mukul", "acme-ci"]));
    // the usernames your API accepts; trust("*") accepts any valid messh signer
    MesshVerifier verifier = MesshVerifier.builder().trust("mukul", "acme-ci").build();
  5. 5

    Try it before writing client code

    messh curl signs as you with a published key, so you can test your API end to end from a terminal.

    Terminal
    $ messh curl --key ~/.ssh/build-bot.pub https://api.acme.dev/deployments
    signed as mukul (keyid BEGCMYMPzN8Mc2ySSaLpu4-OMur8oB-mMSK7yU555R4)
    HTTP/1.1 200 OK
    [{"env":"staging","status":"live"}]

Sign requests#

Create one client in the agent and send requests through it. Signing is local and takes microseconds; there is no extra network call.

agent.tsagent.pymain.gomain.rsAgent.java
import { MesshClient, keyFile } from "@messh/sdk"

// Signs as mukul with the build-bot key. Create one client per process.
const client = await MesshClient.create({
  identity: "mukul",
  key: keyFile("~/.ssh/build-bot"),
})

const res = await client.fetch("https://api.acme.dev/deployments", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ env: "staging" }),
})
console.log(res.status)
from messh import Client, key_file

# Signs as mukul with the build-bot key. Create one client per process.
client = Client("mukul", key=key_file("~/.ssh/build-bot"))

res = client.post("https://api.acme.dev/deployments", json={"env": "staging"})
print(res.status, res.json())
package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"messh.io/go/messh"
)

func main() {
	// Signs as mukul with the build-bot key. Create one client per process.
	home, _ := os.UserHomeDir()
	key := messh.KeyFile(filepath.Join(home, ".ssh", "build-bot"))
	client, err := messh.NewClient("mukul", messh.WithKey(key))
	if err != nil {
		panic(err)
	}
	url := "https://api.acme.dev/deployments"
	body := strings.NewReader(`{"env":"staging"}`)
	resp, err := client.Post(url, "application/json", body)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	fmt.Println(resp.Status)
}
use messh::{Client, KeySource};

fn main() -> Result<(), messh::Error> {
    // Signs as mukul with the build-bot key. Create one client per process.
    let client = Client::builder("mukul")
        .key(KeySource::File("~/.ssh/build-bot".into()))
        .build()?;

    let url = "https://api.acme.dev/deployments";
    let (status, body) = client.send("POST", url, Some(br#"{"env":"staging"}"#))?;
    println!("{status} {}", String::from_utf8_lossy(&body));
    Ok(())
}
import io.messh.KeySource;
import io.messh.MesshClient;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;

public class Agent {
  public static void main(String[] args) throws Exception {
    // Signs as mukul with the build-bot key. Thread-safe: create one per process.
    Path key = Path.of(System.getProperty("user.home"), ".ssh", "build-bot");
    MesshClient client = MesshClient.builder("mukul").key(KeySource.file(key)).build();

    URI uri = URI.create("https://api.acme.dev/deployments");
    byte[] body = "{\"env\":\"staging\"}".getBytes(StandardCharsets.UTF_8);
    HttpResponse<String> res =
        client.send("POST", uri, body, HttpResponse.BodyHandlers.ofString());
    System.out.println(res.statusCode());
  }
}

What the client does

  1. Loads the key, oncean OpenSSH or PKCS#8 file, an env var, a seed, or your own signer
  2. Hashes the bodySHA-256 into Content-Digest
  3. Signs with Ed25519method, host, path, query, digest and Signature-Agent, with a time and a nonce
  4. Sendswith Signature-Agent, Signature-Input and Signature

Verify requests#

Add the verifier to your API once. Handlers only see requests that passed, with the signer attached. can() stands for your own authorization: messh says who, you decide what.

server.ts · Expressmain.py · FastAPImain.go · net/httpmain.rs · any frameworkDeployments.java · JDK HttpServer
import express from "express"
import { messhAuth } from "@messh/sdk/verify"

const app = express()

// Mount before express.json(): it reads the raw body to check Content-Digest,
// then sets req.body for JSON.
app.use("/api", messhAuth({ trust: ["mukul"] }))

app.post("/api/deployments", (req, res) => {
  const agent = req.agent // { identity: "mukul", keyName: "build-bot", … }
  if (!can(agent, "deploy", req.body.env)) return res.status(403).end()
  res.json({ ok: true })
})

app.listen(3000)
from fastapi import Depends, FastAPI, HTTPException
from messh.verify import Agent, MesshAuthMiddleware, current_agent

app = FastAPI()
app.add_middleware(MesshAuthMiddleware, trust=["mukul"], paths=["/api/"])

@app.post("/api/deployments")
def deploy(body: dict, agent: Agent = Depends(current_agent)):
    if not can(agent, "deploy", body["env"]):
        raise HTTPException(403)
    return {"ok": True}
package main

import (
	"net/http"

	"messh.io/go/messh/verify"
)

func main() {
	v, err := verify.New(verify.Trust("mukul"))
	if err != nil {
		panic(err)
	}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /deployments", func(w http.ResponseWriter, r *http.Request) {
		agent := verify.AgentFrom(r.Context()) // Identity "mukul", KeyName "build-bot"
		if !can(agent, "deploy") {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		w.Write([]byte(`{"ok":true}`))
	})
	http.ListenAndServe(":8080", v.Middleware(mux))
}
use messh::verify::{Request, VerifyConfig, Verifier};

// Create once and share: it caches key directories.
let verifier = Verifier::new(VerifyConfig::trust(["mukul"]));

// In your handler or middleware (axum, actix, hyper): the method, the full
// URL, the headers and the raw body. It blocks on a cache miss, so on
// tokio run it in spawn_blocking.
let req = Request { method: "POST", url: &url, headers: &headers, body: &body };
match verifier.verify(&req) {
    Ok(agent) if can(&agent, "deploy") => respond(200, r#"{"ok":true}"#),
    Ok(_) => respond(403, r#"{"error":"forbidden"}"#),
    Err(e) => respond(e.status(), &e.to_json()), // 401 or 403, JSON body
}
import com.sun.net.httpserver.HttpServer;
import io.messh.verify.Agent;
import io.messh.verify.MesshHttpFilter;
import io.messh.verify.MesshVerifier;
import java.net.InetSocketAddress;

public class Deployments {
  public static void main(String[] args) throws Exception {
    MesshVerifier verifier = MesshVerifier.builder().trust("mukul").build();
    HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
    server.createContext("/deployments", ex -> {
      Agent agent = (Agent) ex.getAttribute(MesshHttpFilter.AGENT);
      ex.sendResponseHeaders(can(agent, "deploy") ? 200 : 403, -1);
      ex.close();
    }).getFilters().add(new MesshHttpFilter(verifier));
    server.start();
  }
}

What the verifier checks

  1. Headers presentSignature, Signature-Input, Signature-Agent, tag="web-bot-auth"
  2. Body matches its digestContent-Digest, SHA-256
  3. Freshcreated within ±60 s and not past expires
  4. Trustedthe identity is in trust, before any key lookup
  5. Key known and activein the signer’s key directory on messh, cached 5 min; new keys within 30 s
  6. Signature valid, never seenEd25519 over the covered components, nonce unused; then your handler runs

Errors#

The same codes in every SDK. A missing or invalid signature gets 401; a valid signature from an identity you don’t trust gets 403.

CodeHTTPWhenFix
missing_signature401No Signature or Signature-Input header.Send through a messh client. Check that no proxy strips the headers.
bad_signature401The signature doesn’t match the request, or something required isn’t signed: method, host, path, query, body digest or Signature-Agent.Sign last. Nothing may rewrite the method, host, path or a covered header afterwards.
digest_mismatch401The body doesn’t match Content-Digest.Don’t re-encode or compress the body after signing.
clock_skew401created is more than 60 s from the server clock, or expires has passed.Sync clocks with NTP. Keep the client’s expiry at 60 s or less.
nonce_replayed401A request with this nonce was already accepted from this signer.Send a new request; clients sign every call afresh. Repeats mean someone replayed traffic.
unknown_key401No such messh user, the keyid isn’t one of their active keys, or messh can’t be reached and nothing is cached. Revoked keys leave the directory; new keys are picked up within 30 s.Run whoami() in the agent, or messh resolve <user>; publish the key with messh publish.
untrusted403The identity isn’t in trust (checked before any key lookup), or Signature-Agent names a registry you don’t accept.Add the username to trust, or the registry to registries.
body_too_large413The body is over the verifier’s limit, 1 MiB by default.Send a smaller body, or raise the limit on the API.

Thrown as VerifyError with .code and .status.Raised as messh.VerifyError with .code and .status.Returned as *verify.Error; use errors.As, then .Code and .Status.Returned as VerifyError, with .code(), .status() and .to_json().Thrown as VerifyException, unchecked, with code() and status(). Clients surface them from the JSON body as error.

Wire format#

What every SDK sends: RFC 9421 HTTP Message Signatures with Ed25519 and tag="web-bot-auth". Signature-Agent names the signer, and the body is covered by an RFC 9530 Content-Digest. Any RFC 9421 library can verify it with the public key from the signer’s key directory.

Signed request
POST /deployments HTTP/1.1
Host: api.acme.dev
Content-Type: application/json
Content-Digest: sha-256=:UBifAFHpBbmMs0QyVmKDnbiIlEcHtzA/GECP6CmP2EI=:
Signature-Agent: "https://messh.io/mukul"
Signature-Input: sig1=("@method" "@authority" "@path" "content-digest" "signature-agent");created=1760882531;expires=1760882591;keyid="BEGCMYMPzN8Mc2ySSaLpu4-OMur8oB-mMSK7yU555R4";alg="ed25519";nonce="b3k2pUe8Qz1";tag="web-bot-auth"
Signature: sig1=:V/vjOvz68IzttX4KVlJmTP/YEhse5r6J9nPfSrcNrkxO0iv5HWjxLWci3MmCLyVKz3xdF2uxn722BdfsRdJoCg==:

{"env":"staging"}
Signature base: the bytes Ed25519 signs
"@method": POST
"@authority": api.acme.dev
"@path": /deployments
"content-digest": sha-256=:UBifAFHpBbmMs0QyVmKDnbiIlEcHtzA/GECP6CmP2EI=:
"signature-agent": "https://messh.io/mukul"
"@signature-params": ("@method" "@authority" "@path" "content-digest" "signature-agent");created=1760882531;expires=1760882591;keyid="BEGCMYMPzN8Mc2ySSaLpu4-OMur8oB-mMSK7yU555R4";alg="ed25519";nonce="b3k2pUe8Qz1";tag="web-bot-auth"

These are real bytes, signed with the conformance key in spec/vectors. All five SDKs produce byte-identical signatures for the same key, time and nonce, and each one’s tests check the shared vectors in web-bot-auth.json.

See also#

↑ ↓ to move↵ to openesc to close