Build a service: sign-up, users, and wallet permits
The whole server side for a service on Beagle identity, with code you can paste: sign people up with Sign in with Beagle, keep your users in your own database, and grant access from the wallet assets they bound in their Beagle profile. No password, no wallet connect on your site.
October 2026What you are building
Three parts, in this order. Each one is a few dozen lines on your server, and the user never gives you a password, a seed phrase, or a key.
| Part | What the user does | What your server does |
|---|---|---|
| 1. Sign up | Clicks Sign in with Beagle, approves once in Beagle | Issues a nonce, verifies the signature, sets a session |
| 2. Users | Nothing | Upserts the user by Beagle userid, logs the sign-in and what they do |
| 3. Permit | Binds a wallet once, in their Beagle profile | Reads the bound wallet from their public record, checks the proof, checks the assets |
A wallet connect on every site means every site runs its own wallet flow, its own binding table, and asks the user to sign again. With Beagle the user binds a wallet once, the wallet signs a message naming their Beagle identity, and that proof sits in their public beagles.eth record. Any service reads it; none of them has to ask.
Beagle Meet (snoopy.fi.chat, goji.fi.chat) runs this design in production: sign-in, user records, and wallet-based gates on rooms. The code below is written fresh for your service; you do not install or run Meet.
npm install express pg @decentnetwork/beagle-connect @decentnetwork/beagle-ui
# beagle-connect brings @decentnetwork/peer (server-side signature check).
# beagle-ui is only used for its React-free subpath @decentnetwork/beagle-ui/wallet-proof.1. Sign up with Sign in with Beagle
There is no separate sign-up. The first verified sign-in creates the user; every later one updates them. The background (doors, the three identities) is in Add Sign in with Beagle to your app.
Browser. One button.
import { signIn } from "@decentnetwork/beagle-connect";
document.querySelector("#signin").addEventListener("click", async () => {
// Open the window inside the click. After an await, popup blockers eat it.
const win = window.open("about:blank", "beagle-connect", "width=420,height=560");
// Single-use nonce from YOUR server. That is the whole replay defence.
const { nonce } = await fetch("/api/auth/nonce", { credentials: "include" }).then((r) => r.json());
// Finds the installed Beagle (127.0.0.1:8766) or falls back to app.beagle.chat.
const who = await signIn({ nonce, existingWindow: win });
// { userid, address, name, avatar, sig, nonce, uiOrigin }
const r = await fetch("/api/auth/beagle", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...who, origin: location.origin }),
});
const out = await r.json();
if (!r.ok) throw new Error(out.error);
render(out.user, out.permit); // permit: { ok, code?, message? }
});Server. Nonce, verify, session, in one file. verifyDetached from @decentnetwork/peer is XEdDSA: a Beagle userid is an X25519 public key, so Ed25519 libraries such as nacl.sign will always say no.
import express from "express";
import { randomBytes, createHmac, timingSafeEqual } from "node:crypto";
import { verifyDetached, base58ToBytes } from "@decentnetwork/peer";
const app = express();
app.use(express.json());
// Exact origins your pages are served from. Never app.beagle.chat:
// the user signs YOUR origin.
const ORIGINS = new Set(["https://example.com"]);
const SECRET = process.env.SESSION_SECRET; // 32+ random bytes, not in git
// ── nonce: single-use, two minutes ──────────────────────────────
const nonces = new Map(); // nonce -> expiry. Use Redis if you run more than one process.
app.get("/api/auth/nonce", (_req, res) => {
const now = Date.now();
for (const [k, exp] of nonces) if (exp < now) nonces.delete(k);
const nonce = randomBytes(18).toString("base64url");
nonces.set(nonce, now + 120_000);
res.json({ nonce });
});
function takeNonce(nonce) {
const exp = nonces.get(nonce);
nonces.delete(nonce);
return exp !== undefined && exp >= Date.now();
}
// ── the one primitive: XEdDSA over the Beagle key, NOT Ed25519 ──
function verifyBeagle(prefix, userid, origin, salt, sigHex) {
try {
const msg = new TextEncoder().encode(`${prefix}\n${origin}\n${salt}`);
return verifyDetached(base58ToBytes(userid), msg, Uint8Array.from(Buffer.from(sigHex, "hex")));
} catch {
return false;
}
}
// ── session: an HMAC'd cookie keyed by the Beagle userid ────────
const mac = (v) => createHmac("sha256", SECRET).update(v).digest("hex");
function setSession(res, userid) {
res.setHeader("Set-Cookie",
`sid=${encodeURIComponent(`${userid}.${mac(userid)}`)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000`);
}
function sessionUser(req) {
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || "");
if (!m) return null;
const v = decodeURIComponent(m[1]);
const i = v.lastIndexOf(".");
const userid = v.slice(0, i);
const got = Buffer.from(v.slice(i + 1));
const want = Buffer.from(mac(userid));
return got.length === want.length && timingSafeEqual(got, want) ? userid : null;
}
// ── sign up / sign in: the same route ───────────────────────────
app.post("/api/auth/beagle", async (req, res) => {
const { userid, nonce, sig, origin } = req.body ?? {};
if (!userid || !nonce || !sig || !origin) return res.status(400).json({ error: "missing fields" });
if (!ORIGINS.has(origin)) return res.status(400).json({ error: "origin not allowed" });
if (!takeNonce(nonce)) return res.status(401).json({ error: "bad or expired nonce" });
if (!verifyBeagle("decent-auth", userid, origin, nonce, sig))
return res.status(401).json({ error: "signature did not verify" });
const denied = await deniedReason(userid);
if (denied) return res.status(403).json({ error: denied, code: "denied" });
const user = await rememberUser(req, userid, req.body, "popup");
setSession(res, userid);
res.json({ user, permit: await permitFor(userid) });
});Opened from inside Beagle. When your service is listed in Beagle's Apps tab, the user arrives already signed in. Read the fragment on page load and post it to a second route.
import { readLaunch } from "@decentnetwork/beagle-connect";
// On page load. null = nothing in the fragment. Throws on a bad or stale one:
// show that, do not treat it as signed out.
const who = await readLaunch();
if (who) {
await fetch("/api/auth/beagle-launch", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(who),
});
}// Opened from Beagle's Apps tab: already signed in, never ask again.
// No nonce you issued, so YOU bound freshness and refuse a repeat.
const usedLaunch = new Map(); // sig -> expiry
app.post("/api/auth/beagle-launch", async (req, res) => {
const { userid, sig, ts } = req.body ?? {};
// The Origin HEADER, not the body: with no nonce to bind, a body origin
// would let an assertion minted for one of your sites be spent at another.
const origin = req.get("origin");
if (!userid || !sig || !Number.isFinite(Number(ts))) return res.status(400).json({ error: "missing fields" });
if (!ORIGINS.has(origin)) return res.status(400).json({ error: "origin not allowed" });
if (Math.abs(Date.now() - Number(ts)) > 120_000) return res.status(401).json({ error: "launch assertion expired" });
if (!verifyBeagle("decent-launch", userid, origin, ts, sig))
return res.status(401).json({ error: "signature did not verify" });
const now = Date.now();
for (const [k, exp] of usedLaunch) if (exp < now) usedLaunch.delete(k);
if (usedLaunch.has(sig)) return res.status(401).json({ error: "launch assertion already used" });
usedLaunch.set(sig, now + 240_000);
const user = await rememberUser(req, userid, req.body, "launch");
setSession(res, userid);
res.json({ user, permit: await permitFor(userid) });
});Verifying against a hostname instead of the full origin (https://example.com). Reusing a nonce, or making one on the client. Accepting decent-launch where you expect decent-auth. Treating name, avatar or address from the popup as proof: they are not signed.
2. Keep your users in your own backend
The Beagle userid is the account key. Store what the person showed you and what they did with you. Their keys stay in Beagle; you never see them. This record is what lets you reward people, reach them later, and refuse service.
-- The account key is the Beagle userid. No password column, no key column.
CREATE TABLE users (
userid TEXT PRIMARY KEY, -- Beagle userid (base58 X25519)
name TEXT, -- display only
avatar_url TEXT, -- display only
carrier_addr TEXT, -- where to reach them: message, add friend, reward
beagles_name TEXT, -- foo.beagles.eth, when they registered one
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Every sign-in: which door, from where. The trail you reward or refuse on.
CREATE TABLE auth_events (
id BIGSERIAL PRIMARY KEY,
at TIMESTAMPTZ NOT NULL DEFAULT now(),
userid TEXT NOT NULL,
door TEXT NOT NULL, -- popup | launch
origin TEXT,
ip TEXT,
user_agent TEXT
);
-- What they did in YOUR service. One row per action you care about.
CREATE TABLE activity (
id BIGSERIAL PRIMARY KEY,
at TIMESTAMPTZ NOT NULL DEFAULT now(),
userid TEXT NOT NULL,
kind TEXT NOT NULL, -- e.g. joined, viewed_signal, posted
detail JSONB
);
-- Refusing service is a row, written before you need it.
CREATE TABLE denials (
userid TEXT PRIMARY KEY,
reason TEXT NOT NULL,
by_userid TEXT,
at TIMESTAMPTZ NOT NULL DEFAULT now()
);Fill the profile from the person's public beagles.eth record when they have one. It is signed and published, unlike the hints in the popup.
import pg from "pg";
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const GATEWAY = "https://ens-gateway.beaglechat.workers.dev";
// The person's public beagles.eth record, or null. Read-only, no key needed.
async function beaglesRecord(userid) {
try {
const r = await fetch(`${GATEWAY}/getAddress/${encodeURIComponent(userid)}`,
{ signal: AbortSignal.timeout(2500) });
if (!r.ok) return null;
const rec = await r.json();
// It must be THIS person's record: written by this key, or naming it as
// carrierUserId (names registered from the mobile wallet are owned by the wallet).
return rec && (rec.owner === userid || rec.texts?.carrierUserId === userid) ? rec : null;
} catch {
return null;
}
}
const clip = (v, n) => (typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null);
async function rememberUser(req, userid, body, door) {
const rec = await beaglesRecord(userid);
// The published record wins; the popup's name/avatar/address are unsigned hints.
const name = clip(rec?.texts?.displayName, 64) ?? clip(body.name, 64);
const avatar = clip(rec?.texts?.avatar, 512) ?? clip(body.avatar, 512);
const carrier = clip(rec?.texts?.carrierAddress, 64) ?? clip(body.address, 64);
const { rows } = await db.query(
`INSERT INTO users (userid, name, avatar_url, carrier_addr, beagles_name)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (userid) DO UPDATE SET
name = COALESCE(EXCLUDED.name, users.name),
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
carrier_addr = COALESCE(EXCLUDED.carrier_addr, users.carrier_addr),
beagles_name = COALESCE(EXCLUDED.beagles_name, users.beagles_name),
last_seen_at = now()
RETURNING *`,
[userid, name, avatar, carrier, rec?.name ?? null],
);
await db.query(
`INSERT INTO auth_events (userid, door, origin, ip, user_agent) VALUES ($1, $2, $3, $4, $5)`,
[userid, door, req.get("origin") ?? null, req.ip, clip(req.get("user-agent"), 256)],
);
return rows[0];
}
async function deniedReason(userid) {
const { rows } = await db.query(`SELECT reason FROM denials WHERE userid = $1`, [userid]);
return rows[0]?.reason ?? null;
}
const track = (userid, kind, detail = null) =>
db.query(`INSERT INTO activity (userid, kind, detail) VALUES ($1, $2, $3)`, [userid, kind, detail]);| Store | Never store |
|---|---|
| userid, name, avatar, Carrier address, beagles.eth name | Passwords: there are none |
| Each sign-in: door, origin, IP, time | Private keys, seed phrases, wallet secrets |
| What they did: joined, viewed, bought, posted | The popup's name/avatar as identity |
| Denials, with a reason and who decided |
Keep carrier_addr. With it your service, or its agent, can message the person, send a friend request, or deliver a reward over the Beagle network, without holding anything of theirs.
3. Grant access from wallet assets
The user binds a wallet once in Beagle: Profile > beagles.eth > bind Solana / Ethereum. Their wallet (Phantom, MetaMask) signs a message naming their Beagle userid, chain, address and time, and Beagle writes the address and that signature into their public record. You read it with no key and no extra prompt.
GET https://ens-gateway.beaglechat.workers.dev/getAddress/<userid>
{
"name": "alice.beagles.eth",
"owner": "<userid>",
"addresses": { "501": "<solana address>" }, // 60 = ETH, 501 = SOL
"texts": {
"displayName": "Alice",
"avatar": "https://…",
"carrierAddress": "<52-char Carrier address>",
"carrierUserId": "<userid>",
"proof.sol": "{\"v\":1,\"msg\":\"…userid: <userid>\\nchain: sol\\naddress: …\\nissued: …\",\"sig\":\"…\"}"
}
}verifyRecordWallet(record, "sol") checks that signature offline and gives you the address, or tells you why not. It also accepts a name registered from the Beagle mobile wallet, where the wallet itself owns the record. Then check the assets on that address your usual way.
import { verifyRecordWallet } from "@decentnetwork/beagle-ui/wallet-proof";
const RPC = process.env.SOLANA_RPC || "https://api.mainnet-beta.solana.com";
const MINT = "YourTokenMint11111111111111111111111111111"; // your SPL token mint
const THRESHOLD = 1000; // whole tokens
// Sum of one SPL token across every token account the wallet owns.
async function splBalance(owner, mint) {
const r = await fetch(RPC, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(3000),
body: JSON.stringify({
jsonrpc: "2.0", id: 1, method: "getTokenAccountsByOwner",
params: [owner, { mint }, { encoding: "jsonParsed" }],
}),
});
const { result, error } = await r.json();
if (error) throw new Error(error.message);
return result.value.reduce((sum, a) => sum + (a.account.data.parsed.info.tokenAmount.uiAmount ?? 0), 0);
}
const yes = new Map(); // userid -> time of the last yes
// { ok: true } | { ok: false, code, message } | { ok: null, code: "unavailable", message }
async function permitFor(userid) {
const at = yes.get(userid);
if (at && Date.now() - at < 60_000) return { ok: true };
const rec = await beaglesRecord(userid);
const wallet = rec ? verifyRecordWallet(rec, "sol") : { ok: false };
if (!wallet.ok) {
return { ok: false, code: "no-wallet",
message: "Bind a Solana wallet in your Beagle profile (Profile > beagles.eth), then sign in again." };
}
let balance;
try {
balance = await splBalance(wallet.address, MINT);
} catch {
// The chain read failed. That is not a no: say so, and decide your policy.
return { ok: null, code: "unavailable", message: "Could not check your wallet right now. Try again in a minute." };
}
if (balance < THRESHOLD) {
return { ok: false, code: "below-threshold", message: `Holding ${THRESHOLD} tokens unlocks this.` };
}
yes.set(userid, Date.now()); // cache a yes briefly; never cache a no for long
return { ok: true };
}
// Put it in front of anything that needs the permit.
async function requirePermit(req, res, next) {
const userid = sessionUser(req);
if (!userid) return res.status(401).json({ error: "Sign in with Beagle first" });
if (await deniedReason(userid)) return res.status(403).json({ error: "denied", code: "denied" });
const p = await permitFor(userid);
if (p.ok !== true) return res.status(p.ok === null ? 503 : 403).json(p);
req.userid = userid;
next();
}
app.get("/api/signals", requirePermit, async (req, res) => {
await track(req.userid, "viewed_signals");
res.json(await latestSignals());
});Ethereum is the same record and the same call with "eth".
// Same record, other chain. Proof: EIP-191 personal_sign by that address.
const w = verifyRecordWallet(rec, "eth"); // { ok, address: "0x…", via }
// ERC-20 balanceOf(address) through eth_call on your RPC:
const data = "0x70a08231" + w.address.slice(2).toLowerCase().padStart(64, "0");
// POST { jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to: TOKEN, data }, "latest"] }
// → hex balance in the token's smallest unit; divide by 10 ** decimals.When the RPC times out, return "could not check" (ok: null, HTTP 503), never "you do not qualify". Whether to let people in during an outage is your policy; decide it on purpose. Cache a yes for about a minute; do not cache a no for long, or someone who just bought in waits for nothing.
The signature proves this Beagle identity controls this address. Balances change every block, so read them at the moment you grant access, and again when the permit expires.
Before you ship
Use Redis or your database if more than one process serves requests.
Scheme and host, as the browser reports location.origin.
Rotating it signs everyone out, which is the point.
Profile from the beagles.eth record when there is one; popup fields only as a fallback.
A row in denials stops sign-in and every permit.
No wallet bound means one clear sentence telling the user where to bind it in Beagle.
503 with a retry message, not 403.