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.
- mIdentity
messh publish --name build-bot - Sign
client.fetch()client.post()client.Do()client.send()client.send()signs locally; the key stays on the machine - Verify
messhAuth()MesshAuthMiddlewarev.Middleware()verifier.verify()MesshHttpFilterchecks signature, time, nonce and key - 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.
mukulA messh username. An agent is a named key under it: messh publish --name build-bot.
BEGCMYMP…555R4The key’s RFC 7638 thumbprint, listed at messh.io/mukul/.well-known/http-message-signatures-directory.
mukulWho 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
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
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
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
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 403untrusted.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
Try it before writing client code
messh curlsigns 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.
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
- Loads the key, oncean OpenSSH or PKCS#8 file, an env var, a seed, or your own signer
- Hashes the bodySHA-256 into
Content-Digest - Signs with Ed25519method, host, path, query, digest and
Signature-Agent, with a time and a nonce - Sendswith
Signature-Agent,Signature-InputandSignature
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.
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
- Headers present
Signature,Signature-Input,Signature-Agent,tag="web-bot-auth" - Body matches its digest
Content-Digest, SHA-256 - Fresh
createdwithin ±60 s and not pastexpires - Trustedthe identity is in
trust, before any key lookup - Key known and activein the signer’s key directory on messh, cached 5 min; new keys within 30 s
- 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.
| Code | HTTP | When | Fix |
|---|---|---|---|
| missing_signature | 401 | No Signature or Signature-Input header. | Send through a messh client. Check that no proxy strips the headers. |
| bad_signature | 401 | The 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_mismatch | 401 | The body doesn’t match Content-Digest. | Don’t re-encode or compress the body after signing. |
| clock_skew | 401 | created 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_replayed | 401 | A 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_key | 401 | No 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. |
| untrusted | 403 | The 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_large | 413 | The 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.
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"}
"@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#
- JS / TS SDK referencePython SDK referenceGo SDK referenceRust SDK referenceJava SDK referenceEvery type and function, with parameters, defaults and errors.
- messh CLIPublish keys and give people access to servers.
- Request inspectorSigned requests to your API as they arrive, in the dashboard (preview).