NoFormLive demo ↗

Integrate NoForm

NoForm is a reference implementation of the open auth.md agent-registration protocol.

Three moving parts, all plain HTTP — no SDK lock-in:

  1. Host the protocol on your domain — reverse-proxy /auth.md + /.well-known/* + /agent/auth/* to noform.dev/a/<slug>/… (or use a Pro custom domain).
  2. Validate the agent-issued credential with /agent/auth/introspect and issue your own session.
  3. Sync users from signed webhooks (claim.confirmed, verified.accepted).
The agent side

The auth.md signup skill

You host auth.md; the companion auth.md-signup skill lets any agent complete the flow. It discovers your discovery files, registers anonymously, has the human confirm the emailed code (the consent gate — the agent never auto-confirms), and stores a scoped, revocable credential per service. Primary credentials are never touched. Works against any auth.md service — NoForm is the reference target.

# install the skill, then just ask your agent
openclaw skills install auth-md-signup

> "Sign me up for NoForm"

The helper

Copy this one file (it's also in the Acme example):

// lib/noform.ts — dependency-free, copy this in (Node runtime)
import crypto from "node:crypto";

export async function introspect(base, slug, apiKey, credential) {
  const r = await fetch(`${base}/a/${slug}/agent/auth/introspect`, {
    method: "POST",
    headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
    body: JSON.stringify({ credential }), cache: "no-store",
  });
  return r.ok ? r.json() : { active: false };
}

export function verifyWebhook(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(signatureHeader ?? ""), b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Next.js

Host the protocol on your own domain with rewrites, then validate the credential in a server component.

// next.config.ts — serve auth.md on your domain
export default {
  async rewrites() {
    const base = "https://noform.dev/a/<slug>";
    return [
      { source: "/auth.md", destination: `${base}/auth.md` },
      { source: "/agent/auth", destination: `${base}/agent/auth` },
      { source: "/agent/auth/:p*", destination: `${base}/agent/auth/:p*` },
      { source: "/.well-known/:p*", destination: `${base}/.well-known/:p*` },
    ];
  },
};

// app/welcome/page.tsx
const user = await introspect(BASE, SLUG, API_KEY, credential);
if (user.active) /* set your session cookie */ ;

React (SPA)

Keep the API key server-side. The agent sends the user to /welcome?credential=… ; a tiny backend route introspects and sets a session, then your SPA reads it normally.

// server route (never expose the API key to the browser)
app.get("/welcome", async (req, res) => {
  const u = await introspect(BASE, SLUG, API_KEY, req.query.credential);
  if (u.active) { setSessionCookie(res, u.email); return res.redirect("/"); }
  res.status(401).send("invalid credential");
});

Node / Express

A signed webhook receiver to sync users, and introspection to log them in.

import crypto from "node:crypto";
app.post("/noform-webhook", express.raw({ type: "*/*" }), (req, res) => {
  if (!verifyWebhook(req.body.toString(), req.get("x-noform-signature"), SECRET))
    return res.status(401).end();
  const evt = JSON.parse(req.body.toString());
  // upsert on evt.type === "claim.confirmed" / "verified.accepted"
  res.json({ ok: true });
});

Python / FastAPI

Verify the webhook signature, then create/sync the user.

import hmac, hashlib, json, os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()

@app.post("/noform-webhook")
async def hook(req: Request):
    raw = await req.body()
    expected = "sha256=" + hmac.new(os.environ["NOFORM_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(req.headers.get("x-noform-signature", ""), expected):
        raise HTTPException(401)
    evt = json.loads(raw)  # upsert on evt["type"]
    return {"ok": True}

Ruby on Rails

HMAC-verify the delivery, then upsert.

post "/noform-webhook" do
  raw = request.body.read
  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", ENV["NOFORM_WEBHOOK_SECRET"], raw)
  halt 401 unless Rack::Utils.secure_compare(request.env["HTTP_X_NOFORM_SIGNATURE"].to_s, expected)
  evt = JSON.parse(raw) # upsert on evt["type"]
end

PHP

Introspect a credential and verify a webhook with the standard library.

function nf_introspect($base,$slug,$apiKey,$credential){
  $ch=curl_init("$base/a/$slug/agent/auth/introspect");
  curl_setopt_array($ch,[CURLOPT_POST=>1,CURLOPT_RETURNTRANSFER=>1,
    CURLOPT_HTTPHEADER=>["Content-Type: application/json","Authorization: Bearer $apiKey"],
    CURLOPT_POSTFIELDS=>json_encode(["credential"=>$credential])]);
  $r=json_decode(curl_exec($ch),true); curl_close($ch); return $r;
}
function nf_verify($raw,$sig,$secret){
  return hash_equals("sha256=".hash_hmac("sha256",$raw,$secret),(string)$sig);
}

WordPress

An mu-plugin registers a REST webhook that creates the user; a /welcome template introspects and logs them in.

add_action('rest_api_init', function () {
  register_rest_route('noform/v1', '/webhook', [
    'methods' => 'POST',
    'permission_callback' => '__return_true',
    'callback' => function ($req) {
      $raw = $req->get_body();
      $sig = $req->get_header('x-noform-signature');
      $ok = hash_equals('sha256=' . hash_hmac('sha256', $raw, NOFORM_SECRET), (string) $sig);
      if (!$ok) return new WP_REST_Response(['error' => 'bad_sig'], 401);
      $evt = json_decode($raw, true);
      // wp_insert_user(...) on claim.confirmed
      return ['ok' => true];
    },
  ]);
});

Supabase

On the webhook, create the user with the service role; on /welcome introspect, then mint a session. Keep both keys server-side.

// webhook receiver (service role)
await supabase.auth.admin.createUser({ email, email_confirm: true });

// /welcome
const u = await introspect(BASE, SLUG, NOFORM_API_KEY, credential);
if (u.active) {
  const { data } = await supabase.auth.admin.generateLink({ type: "magiclink", email: u.email });
  // redirect the user through data.properties.action_link
}

WorkOS AuthKit

Create the WorkOS user on provisioning, then start an AuthKit session on /welcome.

import { WorkOS } from "@workos-inc/node";
const workos = new WorkOS(process.env.WORKOS_API_KEY);

// webhook: on claim.confirmed / verified.accepted
await workos.userManagement.createUser({ email, emailVerified: true });

// /welcome: introspect → then start an AuthKit session for that user

Any HTTP backend

There's no SDK requirement — it's plain HTTP. Serve the three files (reverse-proxy to noform.dev/a/<slug>/… or use a Pro custom domain), POST to /agent/auth/introspect with your API key to validate a credential, and verify webhook deliveries with an HMAC-SHA256 check of the raw body against the X-NoForm-Signature header.

See it working

acme.noform.dev is a real app built with every piece above — host, introspect, welcome.