Add Sign in with Beagle to your app
One npm package, three calls, and about twenty lines on your server. What to install, what to import, and what to verify.
September 2026The short version
One dependency, three client calls, one server check. If you only read one page before wiring this up, read this one — the reasoning behind it is in Sign in with Beagle.
signIn({ nonce })— find out who the visitor isreadLaunch()— they opened your app from inside BeagleaddFriend({ address, name })— introduce two people
Three identities that must not be mixed
One person often carries three keys. They are not aliases for each other, and mixing them is the most common integration bug.
| Field | What it is | Who signs with it |
|---|---|---|
userid | Beagle identity (Carrier X25519, base58). Friend requests, sign-in popup, Apps launch. | Beagle |
address on a wallet POST | A chain wallet — Solana pubkey or 0x… . The session id on the wallet path. | The wallet |
carrierAddress | A Carrier destination, 52 characters. Used to message, add as friend, send a reward. | Nobody — it is an unsigned extra, never proof |
And never treat a Carrier address as a wallet. A Carrier address tells you where to reach someone; it proves nothing on its own.
Three doors a user can arrive through
All three mint the same session. Get a single-use nonce from your own host first, and always sign your origin — never app.beagle.chat.
| Who is signing | Door | Signed prefix |
|---|---|---|
| A browser on app.beagle.chat, or the local CLI on 127.0.0.1:8766 | Popup at /connect | decent-auth |
| Someone already inside Beagle who opened your app from the Apps tab | URL fragment #beagle= | decent-launch |
| iOS / Android Beagle, or any wallet holding Solana or ETH | Signs locally, no popup | beagle-meet-wallet |
GET /api/auth/nonce -> single-use, ~120s
POST /api/auth/decent { userid, signature, ... }
POST /api/auth/beagle-launch { assertion from #beagle= }
POST /api/auth/wallet { address, signature, ... }Verifying a decent-launch assertion as if it were decent-auth will fail, and a service that accepts either interchangeably has no security. Also verify against location.origin, not a bare hostname — one integration was broken for months by exactly that mismatch.
Write the code
Do not hand-roll the popup or the crypto. @decentnetwork/beagle-connect is the client library, and it is the only dependency you add — it pulls @decentnetwork/peer itself, which is what your server verifies with.
npm install @decentnetwork/beagle-connect
# package.json — that is the only dependency you add.
# It pulls @decentnetwork/peer itself, which is what your server verifies with.Sign in. A popup opens, the user sees your origin and their identity, and approves. signIn verifies the signature before it resolves, but that check is for your UI — verify again on your server before you mint a session.
import { signIn } from "@decentnetwork/beagle-connect";
// The nonce MUST come from your server and be single-use.
// That is the whole replay defence.
const { nonce } = await fetch("/api/auth/nonce").then((r) => r.json());
const who = await signIn({ nonce });
// { userid, address, name, avatar, sig, nonce, uiOrigin }
await fetch("/api/auth/beagle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(who),
});They arrived from the Apps tab. Beagle puts a signed assertion in the URL fragment, so it never reaches a server log or a Referer header. readLaunch verifies it, rejects anything older than two minutes, and strips the fragment from the address bar.
import { readLaunch } from "@decentnetwork/beagle-connect";
// null when there is nothing to read. Throws when a fragment is present
// but invalid or stale — surface that, do not treat it as signed out.
const who = await readLaunch();
if (who) {
await fetch("/api/auth/beagle-launch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(who),
});
}Verify on your server. Both flows are the same primitive with a different prefix — and it is XEdDSA, not Ed25519, because a Carrier key is X25519 and the public half *is* the userid.
import { verifyDetached, base58ToBytes } from "@decentnetwork/peer";
const hexToBytes = (h) => Uint8Array.from(h.match(/../g).map((b) => parseInt(b, 16)));
function check(prefix, userid, origin, salt, sigHex) {
const msg = new TextEncoder().encode(`${prefix}\n${origin}\n${salt}`);
return verifyDetached(base58ToBytes(userid), msg, hexToBytes(sigHex));
}
check("decent-auth", userid, MY_ORIGIN, nonce, sig); // from signIn
check("decent-launch", userid, MY_ORIGIN, ts, sig); // from readLaunchA launch carries no nonce you issued, so your server must (1) verify the signature over the decent-launch prefix, (2) reject a timestamp more than 120s away, and (3) remember every sig it has accepted for that window and refuse a repeat. Skip the third and the assertion is a bearer token valid for two minutes — which is long enough.
Introduce two people. Consent is per action and per popup; there is no token your site can keep.
import { addFriend } from "@decentnetwork/beagle-connect";
// address is the `address` field from THEIR signIn — a userid alone
// is not enough to address a friend request.
await addFriend({ address: theirCarrierAddress, name: theirDisplayName });Signed: the prefix, your origin, and the nonce or timestamp. NOT signed: name, avatar, punkId, address. Those are display hints so you can render a person instead of a key. Treat them as profile data — never as identity, and never as authorisation.