Sign in with Chain Daddy
Your site or app sends a visitor to Chain Daddy, they approve it on chaindaddy.io, and they come back with a signed statement that says who they are at your site, and, if you ask and they agree, which wallets they control, which crowns they hold and whether they hold your coin. Any site can do it: there is nothing to register.
It is standard OpenID Connect: the authorization code flow with PKCE, so any stock library works (openid-client, Auth.js, go-oidc, Authlib, Spring Security, .NET). A site gets an ID token: no refresh token and no access to the Chain Daddy API on the user's behalf. After sign-in you keep your own session. AI apps are the exception; see AI apps and MCP clients.
Quickstart: no registration
Try it on your own computer first. Sign-in runs on http://localhost:3000 with nothing hosted and nothing registered. Chain Daddy has a client built in for this, chaindaddy-dev: skip step 1; steps 2 and 4 say which value to use on localhost. Or have the three pieces written for you:
curl -s 'https://api.chaindaddy.io/api/v2/oauth/snippet?origin=http://localhost:3000' |
jq -r '(.notes[] | "# NOTE: \(.)"), (.steps[] | "\n# \(.title)\n\(.code)")'Save them as public/index.html, public/callback.html and server.mjs, run npm install express jose and node server.mjs, open http://localhost:3000 and press the button. What the development client does differently from your own site is in Developing locally.
For your site, have it written for you too. The same request with your site's address returns all of it, filled in:
curl -s 'https://api.chaindaddy.io/api/v2/oauth/snippet?origin=https://YOUR_SITE&symbol=YOUR_COIN&min=100' |
jq -r '(.notes[] | "# NOTE: \(.)"), (.steps[] | "\n# \(.title)\n\(.code)")'steps holds the four pieces in order: client.json, the sign-in page, the callback page and the server; notes says what they assume and anything left out. Leave out min and any amount of the coin counts; leave out symbol too if you do not check for a coin. There is no chain parameter: the answer asks about the ticker's token on each chain it is registered on, so remove any token that is not yours from chaindaddy_claims and from GATE. A coding agent connected to Chain Daddy's MCP server gets the same answer from the get_sign_in_snippet tool.
By hand, the same four pieces. Replace YOUR_SITE with your site's host, such as example.com.
1. Host a client.json. Its URL is your client id. On localhost, skip this step.
{
"client_id": "https://YOUR_SITE/client.json",
"client_name": "Your Site",
"redirect_uris": ["https://YOUR_SITE/callback.html"],
"token_endpoint_auth_method": "none"
}client_id must be exactly the URL the file is served from. Serve it with a plain 200 (no redirect), under 64 KB, from a public https host. Each redirect URI must be https on that same origin. The consent screen shows your host and your client_name. Two more members are optional: chaindaddy_claims fixes what your site asks about a visitor's coins (Holdings and crowns), and chaindaddy_crown earns the Verified badge.
2. Add the button to the page people sign in from.
<script src="https://js.chaindaddy.io/v1/cd.js"></script>
<cd-signin-button client-id="https://YOUR_SITE/client.json"
redirect-uri="https://YOUR_SITE/callback.html"
scope="openid wallets"></cd-signin-button>
<p id="signed-in"></p>
<p id="signin-error" role="alert"></p>
<script>
// Show who your server says is signed in.
fetch('/me').then((r) => (r.ok ? r.json() : null)).then((me) => {
if (me) document.querySelector('#signed-in').textContent = 'Signed in as ' + me.wallet;
});
document.querySelector('cd-signin-button').addEventListener('cd-signin', async (e) => {
const { id_token } = await ChainDaddy.exchangeCode(e.detail);
const res = await fetch('/session', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id_token }) });
if (!res.ok) {
const { message } = await res.json().catch(() => ({}));
document.querySelector('#signin-error').textContent = message || 'Sign-in did not work. Try again.';
return;
}
location.reload();
});
</script>On localhost: client-id="chaindaddy-dev" and redirect-uri="http://localhost:3000/callback.html".
3. Add the callback page at https://YOUR_SITE/callback.html. It is the same on localhost.
<script src="https://js.chaindaddy.io/v1/cd.js"></script>
<p id="signin-error" role="alert"></p>
<script>
ChainDaddy.handleCallback().then(async (result) => {
if (!result) return; // popup: handed back and closed
const { id_token } = await ChainDaddy.exchangeCode(result); // popup blocked: finish here
const res = await fetch('/session', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id_token }) });
if (!res.ok) {
const { message } = await res.json().catch(() => ({}));
document.querySelector('#signin-error').textContent = message || 'Sign-in did not work. Try again.';
return;
}
location.replace('/');
});
</script>4. On your server, verify the ID token, then start your own session. This is a whole Express app in plain JavaScript. Save it as server.mjs, with those three files in a public folder beside it (the page with the button as index.html), then run npm install express jose and node server.mjs:
import express from 'express';
import { randomUUID } from 'node:crypto';
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://api.chaindaddy.io/api/v2/oauth/jwks'));
// ID tokens already accepted, until they expire. A real deployment keeps these in its session store or Redis.
const seen = new Map();
async function verifyChainDaddy(idToken) {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://api.chaindaddy.io', audience: 'https://YOUR_SITE/client.json', algorithms: ['ES256'],
});
if (payload.typ !== 'signin+v1') throw new Error('not a Chain Daddy sign-in');
const now = Date.now() / 1000;
seen.forEach((exp, jti) => { if (exp < now) seen.delete(jti); });
if (!payload.jti || seen.has(payload.jti)) throw new Error('replayed sign-in');
seen.set(payload.jti, payload.exp ?? now + 300);
return payload; // sub: the person's id at this site; wallets[0]: the wallet they signed in with
}
// Your own sessions, by the id in the cookie, for a day. A real deployment keeps these in its session store or Redis.
const sessions = new Map();
const DAY = 24 * 60 * 60 * 1000;
const sessionId = (req) => /(?:^|; )session=([^;]+)/.exec(req.headers.cookie ?? '')?.[1] ?? '';
const sessionOf = (req) => {
const user = sessions.get(sessionId(req));
if (user && user.expires > Date.now()) return user;
sessions.delete(sessionId(req));
};
const app = express();
app.use(express.json());
app.use(express.static('public')); // client.json, the sign-in page (index.html) and callback.html
app.post('/session', async (req, res) => {
let payload;
try {
payload = await verifyChainDaddy(req.body.id_token);
} catch {
return res.status(401).json({ error: 'sign_in_failed', message: 'That sign-in could not be checked. Sign in again.' });
}
const id = randomUUID();
sessions.set(id, { sub: payload.sub, wallet: payload.wallets?.[0], expires: Date.now() + DAY });
res.cookie('session', id, { httpOnly: true, secure: req.hostname !== 'localhost', sameSite: 'lax', maxAge: DAY });
res.json({ ok: true });
});
// Who is signed in. The sign-in page shows it.
app.get('/me', (req, res) => {
const user = sessionOf(req);
if (!user) return res.status(401).json({ error: 'signed_out', message: 'Sign in first.' });
res.json({ sub: user.sub, wallet: user.wallet });
});
app.listen(3000);On localhost: audience: 'chaindaddy-dev' in place of 'https://YOUR_SITE/client.json', and no client.json in public.
Open https://YOUR_SITE (or http://localhost:3000), press the button and approve on chaindaddy.io. You should see the page come back with "Signed in as 0x…", the wallet you signed in with. No wallet? Sign in on chaindaddy.io with email, Google or Apple, and one is made for you. Before anyone signs in, the browser's console shows a red 401 from /me: that is the server saying nobody is signed in yet.
Step 4 is not optional: a token checked only in the browser proves nothing. The server accepts each ID token once, by its jti, and its own session lasts a day. Prefer a stock OpenID Connect library? Use your client.json URL as the client id with the Node or Auth.js setup below, as a public client with no secret. To see the consent screen and an ID token before you build, sign in on the demo page. There is no test mode: Test sign-in.
Developing locally
On http://localhost, with nothing hosted. Chain Daddy has one client built in for this: chaindaddy-dev. Use it in place of your client.json address while you build. Skip step 1 and change three values:
| Where | Value |
|---|---|
The button's client-id | chaindaddy-dev |
The button's redirect-uri | http://localhost:3000/callback.html |
The server's audience | 'chaindaddy-dev' |
The snippet request with origin=http://localhost:3000 returns the three pieces with these values already in. Run node server.mjs, open http://localhost:3000 and press the button. The sign-in window is titled "Local development". Allow it, and the page comes back with "Signed in as 0x…". Any port works. The redirect must be http on localhost, 127.0.0.1 or [::1]. Open the page as localhost: the quickstart's session cookie is secure on every other host, and Safari keeps a secure cookie only over https.
Differences from your own site's sign-in:
- It asks every time. Nothing is remembered, and it does not appear under Settings → Connected sites.
- Its user ids are its own. A person's
subhere is not theirsubat your site. Do not carry users over. - It answers nothing about holdings or crowns. The scopes are
openid,walletsandprofile. So a holders-only route refuses everyone on localhost: test its logic with Test your gate, which needs no sign-in. - Its tokens name
chaindaddy-devas their audience, so a server that checks for your own client id refuses them.
Before you ship, host your client.json (step 1) and put your own three values back.
At a public address, with your own client.json. To try your real client id before the site is live, or to test a coin gate, the site needs a public https address: a tunnel to your machine, or a preview deployment. With the server running, cloudflared tunnel --url http://localhost:3000 needs no account and prints an address such as https://four-random-words.trycloudflare.com. Put it in place of https://YOUR_SITE in client.json, on the button and in the server's audience, restart the server and open that address. The address is new each time the tunnel starts.
localhost cannot be your own client id: Chain Daddy fetches your client.json, so http://localhost:3000/client.json stops on the sign-in page with "This sign-in link doesn't work". A registered client may list http://localhost redirects of its own.
Check your client.json
If the sign-in page says This site's sign-in isn't set up correctly, Chain Daddy could not use your client.json. The page does not say why, so check the file yourself:
- Its address is
httpson a public host name, written in lowercase, with no port, no?queryand no#fragment. - It answers
200by itself within 5 seconds: no redirect, no login page, no404. - It is a JSON object under 64 KB.
client_idis that address, character for character.client_nameis set, and has none of "Chain Daddy", "official", "support" or "wallet" in it.redirect_urislists 1 to 20 addresses, eachhttpson the same origin as the file, and the one your sign-in uses is among them exactly. Anhttp://localhostredirect in the file works for an AI app only.token_endpoint_auth_methodis"none", and there is noclient_secret.chaindaddy_claims, if you set it, is a valid request (Holdings and crowns).
URL=https://YOUR_SITE/client.json
curl -sS -m 5 -w '%{stderr}status=%{http_code} type=%{content_type} redirect=%{redirect_url} bytes=%{size_download}\n' "$URL" |
jq --arg url "$URL" '{
client_id_is_this_url: (.client_id == $url),
redirects_on_this_origin: (.redirect_uris | length > 0 and all(startswith(($url | split("/")[:3] | join("/")) + "/"))),
no_secret: (has("client_secret") | not),
auth_method_none: (.token_endpoint_auth_method == "none")
}'You want status=200, nothing after redirect=, bytes under 65536, and true four times. A change to the file takes up to five minutes to apply.
Endpoints
| Issuer | https://api.chaindaddy.io |
| Discovery | https://api.chaindaddy.io/.well-known/openid-configuration |
| Authorize (the consent page) | https://chaindaddy.io/_oauth/authorize |
| Token | https://api.chaindaddy.io/api/v2/oauth/token |
| Userinfo | https://api.chaindaddy.io/api/v2/oauth/userinfo |
| Keys (JWKS) | https://api.chaindaddy.io/api/v2/oauth/jwks |
Use discovery rather than hard-coding the rest.
Register your site
You only need to register for a server client with a secret. Registering needs a Developer Beta Program membership, which takes any paid plan and the program agreement signed. A client costs nothing more. The easiest way is the developer portal: Sign-in in chaindaddy.io/_developer, New client. It shows each client's id, which of its redirect hosts the consent screen marks Verified, and a server client's secret once, when you create it.
Or call the API with your signed-in session or a cd_live_ key:
curl -X POST https://api.chaindaddy.io/api/v2/developer/oauth-clients \
-H "Authorization: Bearer $CD_LIVE_KEY" -H "Content-Type: application/json" \
-d '{"name":"My Store","redirectUris":["https://example.com/auth/callback"],"confidential":true,
"crown":{"chainKey":"base","crownId":8}}'
# → {"id":"cdc_…","name":"My Store","confidential":true,"clientSecret":"cd_oauth_…",…}redirectUris: 1 to 10, matched exactly (no wildcards, no fragments).httpsonly, excepthttp://localhostandhttp://127.0.0.1for development.confidential:truefor a server that can keep a secret. Thecd_oauth_…secret is shown once. Leave itfalsefor a single-page app, a game or a native app: those use PKCE with no secret.name: up to 40 characters. It may not contain "Chain Daddy", "official", "support" or "wallet".crown(optional): your coin page. It earns the consent screen's verified mark when you hold or manage that crown AND the redirect URI's host is one of the crown's verified sites (or a subdomain of one, unless the site is set to This host only). Without it the screen says "Unverified site".
List, edit and delete with GET, PATCH and DELETE on /api/v2/developer/oauth-clients[/{id}]. Deleting turns the client off at once. From the CLI: chaindaddy oauth client create --name "My Store" --redirect-uri https://example.com/auth/callback --confidential --page FLUXGP, and list, get, update, delete (CLI reference).
Scopes and claims
| Scope | Adds |
|---|---|
openid (required) | sub, chain, and the standard claims |
wallets | wallets: every wallet linked to the person's Chain Daddy account, the one they signed in with first |
profile | identity: the identity they chose to publish (handle, platform, verified, displayName and picture) |
The ID token (ES256, five minutes) carries:
| Claim | |
|---|---|
iss | https://api.chaindaddy.io |
aud | your client id |
typ | always signin+v1. Check it. |
sub | the person's user id at your site: see Subject identifiers |
chain | evm or solana: the chain of the wallet they signed in with |
nonce | the nonce you sent |
iat, exp, jti | standard |
auth_time | only when sub is the wallet (older clients) |
Re-check holdings
Sign-in tells you who someone is at that moment. To check later that they still hold a coin, ask for the wallets scope, keep the wallets, and read each one's balance with the keyless holdings read. It needs no API key and no registered site:
curl https://api.chaindaddy.io/api/v2/public/holdings/base/<token address>/<wallet>
# {"chain":"base","chainId":"eip155:8453","token":"0x…","wallet":"0x…","balance":"250000000000000000000","holds":true}balance is in the token's smallest unit. Add the balances of the person's wallets on that chain to get what they hold. Each caller gets 60 of these reads a minute, and an answer can be up to about 3 minutes old. A 503 means the chain could not be read, not that the wallet is empty. Details are in Public reads. You can also read the balance from the chain yourself.
Subject identifiers
sub is stable: the same person always gets the same sub at your site. Key your users by it.
- A new client gets a per-site
sub: an opaque 43-character string. Clients share onesubper visitor only when, at the time each is created, it has acrownyou hold or manage and itshttpsredirect host is one of that crown's verified sites. So verify the site first, then create the client: this is decided once. A client'shttpsredirect URIs must all be on one host, and a client that shares its site'ssubcannot move them to another. - An older client keeps the wallet as
sub(lowercase hex on EVM, base58 on Solana).
The developer portal shows which kind each client is. To learn the wallet itself, ask for the wallets scope and read wallets[0], the wallet they signed in with. Use the wallet, not sub, wherever Chain Daddy wants an address, for example ?wallet= on your store's entitlements.
There is no email in the token, and no balance unless you ask for one and the person agrees: see Holdings and crowns.
Add the button
The JavaScript SDK gives you a button, the popup and the callback in one script tag: steps 2 and 3 of the quickstart. A registered client puts its cdc_… id in client-id. The button fires cd-signin with the code; the page exchanges it with ChainDaddy.exchangeCode (or your server does) and your server verifies the ID token.
Branding. Use the button as it comes: the text is "Sign in with Chain Daddy" or "Continue with Chain Daddy", the crown is unaltered, theme is light or dark, and it is at least 40 pixels tall. If you draw your own button, keep the same words and the crown, and do not make it read as a wallet asking to sign or pay.
Quickstart: Node with openid-client
import * as client from 'openid-client';
// A client.json site: a public client. No secret; PKCE is the proof.
const config = await client.discovery(
new URL('https://api.chaindaddy.io'),
'https://YOUR_SITE/client.json',
{ id_token_signed_response_alg: 'ES256' },
client.None(),
);
// A registered client with a secret, instead:
// client.discovery(issuer, process.env.CD_CLIENT_ID!, { id_token_signed_response_alg: 'ES256' },
// client.ClientSecretPost(process.env.CD_CLIENT_SECRET!));
// Sign-in: remember verifier, state and nonce in the visitor's session.
const verifier = client.randomPKCECodeVerifier();
const state = client.randomState();
const nonce = client.randomNonce();
const url = client.buildAuthorizationUrl(config, {
redirect_uri: 'https://YOUR_SITE/auth/callback', // listed in your client.json
scope: 'openid wallets',
code_challenge: await client.calculatePKCECodeChallenge(verifier),
code_challenge_method: 'S256',
state,
nonce,
});
// redirect the browser to url
// Callback:
const tokens = await client.authorizationCodeGrant(config, new URL(req.url, 'https://YOUR_SITE'), {
pkceCodeVerifier: verifier,
expectedState: state,
expectedNonce: nonce,
});
const claims = tokens.claims()!;
if (claims.typ !== 'signin+v1') throw new Error('not a Chain Daddy sign-in');
const userId = claims.sub; // key your user by this
const wallet = (claims.wallets as string[])[0]; // the wallet they signed in with ("wallets" scope)Quickstart: Auth.js
For a client.json site, which is a public client: no clientSecret, and token_endpoint_auth_method: 'none'. Without that line Auth.js sends client authentication anyway and the token call answers 401 invalid_client. Auth.js returns to its own callback (https://YOUR_SITE/api/auth/callback/chaindaddy in Next.js), so list that address in your redirect_uris.
providers: [{
id: 'chaindaddy',
name: 'Chain Daddy',
type: 'oidc',
issuer: 'https://api.chaindaddy.io',
clientId: 'https://YOUR_SITE/client.json',
authorization: { params: { scope: 'openid wallets' } },
checks: ['pkce', 'state', 'nonce'],
client: { token_endpoint_auth_method: 'none', id_token_signed_response_alg: 'ES256' },
profile(p) {
if (p.typ !== 'signin+v1') throw new Error('not a Chain Daddy sign-in');
return { id: p.sub, name: p.identity?.handle ?? p.wallets?.[0] };
},
}]A registered client with a secret changes these lines:
clientId: process.env.CD_CLIENT_ID, // cdc_…
clientSecret: process.env.CD_CLIENT_SECRET,
client: { id_token_signed_response_alg: 'ES256' },Verifying an ID token yourself
If you receive the ID token outside a library (from your SPA, say), verify it on your server:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://api.chaindaddy.io/api/v2/oauth/jwks'));
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://api.chaindaddy.io',
audience: process.env.CD_CLIENT_ID,
algorithms: ['ES256'],
});
if (payload.typ !== 'signin+v1') throw new Error('not a Chain Daddy sign-in');
if (payload.nonce !== expectedNonce) throw new Error('replayed sign-in');expectedNonce is a nonce your server made and kept in the visitor's session before the sign-in began. If the browser made the nonce, as the button does, comparing it proves nothing, because anyone holding the token can read the nonce from it. Refuse a jti you have already accepted instead, as in the quickstart.
Holdings and crowns
Ask whether the person holds your coin, and which crowns they hold, with a standard OpenID Connect claims request. Put it in your client.json, as chaindaddy_claims. The button needs nothing more.
{
"client_id": "https://YOUR_SITE/client.json",
"client_name": "Your Site",
"redirect_uris": ["https://YOUR_SITE/callback.html"],
"token_endpoint_auth_method": "none",
"chaindaddy_claims": {"id_token":{"holdings":{"tokens":[{"chain":"base","address":"0xYOUR_TOKEN","min":"100"}]}}}
}address is your coin's contract address. Your coin's page on chaindaddy.io shows it, and the keyless coin read returns it as tokenAddress: GET https://api.chaindaddy.io/api/v2/public/coins/{chain}/{symbol}, for example /api/v2/public/coins/base/FLUXGP (Public reads).
With chaindaddy_claims set, a sign-in that asks for anything else is refused. A change to client.json takes a few minutes to apply, and an invalid chaindaddy_claims stops sign-in at your site until you fix it.
A registered client has no client.json, so it sends the same object as the claims request parameter instead: the button's claims attribute, or the claims option of ChainDaddy.signIn or a stock library. On the button it is the request as JSON, in single quotes:
<cd-signin-button client-id="cdc_…" redirect-uri="http://localhost:3000/callback.html" scope="openid wallets"
claims='{"id_token":{"holdings":{"tokens":[{"chain":"base","address":"0xYOUR_TOKEN","min":"100"}]}}}'></cd-signin-button>| Request | Meaning |
|---|---|
holdings.tokens | up to 5 of {chain, address, min}. min is in whole tokens ("100", "0.5"); leave it out to ask "holds any" |
holdings.share | "boolean" (the default): only yes or no. "balance": also the balance, which the consent screen lists on its own line |
crowns.of | up to 5 of {chain, id}: is this person the holder or a manager of that crown |
crowns.all | true: every crown the person holds |
groups.of | up to 5 of {chain, id}: which of that coin's holder groups the person is in. A group is whatever its creator made it: a holding, the coin's members, a list of invited wallets, or anyone signed in |
The consent screen lists each coin and crown you ask about, and the ID token answers:
{
"holdings": [{ "chain": "base", "token": "0x…", "min": "100", "held": true }],
"crowns": [{ "chain": "base", "id": 8, "symbol": "FLUXGP", "role": "holder" }]
}heldadds up every wallet linked to the person's account on the token's chain, and compares againstmin.heldisnullwith"error": "unavailable"when the balance could not be read. Treatnullas "ask again", not as yes.balance(whole tokens, as a string) is present only withshare: "balance".roleisholderormanager.crowns.alllists crowns held, not managed.A crown the person does not hold is not in
crowns. A crown that could not be read is not in it either, and the token then carries"crowns_error": "unavailable". So when you gate on a crown and it is missing, checkcrowns_errorfirst: if it is there, ask the person to sign in again in a minute.javascriptconst crown = (payload.crowns ?? []).find((c) => c.chain === 'base' && c.id === 8); if (!crown && payload.crowns_error) return res.status(503).send('We could not check your crown just now. Sign in again in a minute.'); if (!crown) return res.status(403).send('This page is for the holder of the crown.');groupshas one line per coin you asked about, with the groups the person is in by id and name ("in": [{"id": "…", "name": "Whales"}], empty when none). Gate on the group'sid, which does not change; its name can. A coin that could not be read is left out and the token carries"groups_error": "unavailable": ask the person to sign in again in a minute.json"groups": [{ "chain": "base", "id": 8, "symbol": "FLUXGP", "in": [{ "id": "7d0f6c1e-…", "name": "Whales" }] }]A person who approved your site signs in again without a click while you ask for the same thing. Change the request and they are asked again.
Token-gating a page. Everyone signs in, and one route is for holders: your server keeps what the ID token said about your coin in the session, and checks it on that route. Match the answer against the coin and the minimum you gate on before you read held. A claims request parameter passes through the visitor's browser, and a visitor can change it to ask about a coin they do hold, or about a lower min; unless your client.json declares the request, the ID token answers whatever was asked. Put the gate in a file of its own, gate.mjs, beside server.mjs:
// What this site gates on, written the way the ID token writes it.
export const GATE = [{ chain: 'base', token: '0xyour_token', min: '100' }];
// true or false, or null when the balance could not be read: not a no, so ask them to sign in again.
export function holdsCoin(payload) {
const holdings = Array.isArray(payload.holdings) ? payload.holdings : [];
const asked = holdings.filter((h) => GATE.some((want) =>
h?.chain === want.chain && h?.token === want.token && h?.min === want.min));
if (asked.some((h) => h.held === true)) return true;
return asked.some((h) => h.held === null) ? null : false;
}Write GATE as the token carries it: chain is the chain's key (base), token is lowercase for an EVM address and exactly as it is for a Solana one, and min is the string your client.json asks for. If it names no min (any amount counts), write min: '0'. With several coins in GATE, holding any one of them passes.
server.mjs reads it with import { holdsCoin } from './gate.mjs'; at the top. POST /session keeps the answer in the session, in place of its sessions.set line:
sessions.set(id, { sub: payload.sub, wallet: payload.wallets?.[0], holder: holdsCoin(payload), expires: Date.now() + DAY });and one more route reads it:
// Only a holder gets this. Everyone else stays signed in and is told why not.
app.get('/members', (req, res) => {
const user = sessionOf(req);
if (!user) return res.status(401).json({ error: 'signed_out', message: 'Sign in first.' });
if (user.holder === null) return res.status(503).json({ error: 'balance_unreadable', message: 'We could not read your balance just now. Sign in again in a moment.' });
if (!user.holder) return res.status(403).json({ error: 'not_a_holder', message: 'This page is for holders of the coin.' });
res.json({ ok: true }); // your holders-only page
});A visitor who does not hold the coin is signed in like anyone else, and /members answers them 403 with a message. A real site sends its holders-only page there, from a folder outside public, with res.sendFile('members.html', { root: 'private' }) in place of res.json({ ok: true }). Until a new chaindaddy_claims applies, the ID token has no answer for your coin, so /members answers a holder 403 as well: sign in again a few minutes later. To refuse the whole sign-in instead, answer the same way from POST /session when holdsCoin(payload) is not true.
Never gate on payload.holdings[0].held alone.
On localhost, /members refuses everyone: the development client answers nothing about holdings. Check the gate's logic with Test your gate, and see a real holder let in once the site is at a public address with your own client.json: your live site, or a tunnel.
The answer is as of sign-in. To check again later, sign them in again (silent while your request is unchanged), or re-check holdings.
Test your gate
holdsCoin reads only the payload, so you can test it with no wallet and no sign-in:
import assert from 'node:assert/strict';
import { GATE, holdsCoin } from './gate.mjs';
// One answer as the ID token carries it, for the first coin in GATE.
const answer = (over = {}) => ({ holdings: [{ ...GATE[0], held: true, ...over }] });
assert.equal(holdsCoin(answer()), true); // holds the coin
assert.equal(holdsCoin(answer({ held: false })), false); // does not
assert.equal(holdsCoin(answer({ token: '0xanother_token' })), false); // holds another coin: must not pass
assert.equal(holdsCoin(answer({ held: null, error: 'unavailable' })), null); // balance not read: ask again, never yes
assert.equal(holdsCoin({}), false); // asked about nothingSave it as gate.test.mjs and run node gate.test.mjs. It prints nothing when every line holds. To see a real refusal from end to end, sign in with a new wallet that holds nothing: the page says "Signed in as" as for anyone, and /members answers 403.
Show your coin's Verified badge
The consent screen can say Verified · the site of $YOURCOIN instead of "Published by your-site.com". There is nothing to register:
- On your coin's page on chaindaddy.io, open Verified sites and verify your domain (a DNS TXT record). A verified site covers its subdomains, unless it is set to This host only. The wallet that holds or manages the crown does this.
- Declare the crown in your
client.json:
{
"client_id": "https://YOUR_SITE/client.json",
"client_name": "Your Site",
"redirect_uris": ["https://YOUR_SITE/callback.html"],
"token_endpoint_auth_method": "none",
"chaindaddy_crown": { "chain": "base", "id": 8 }
}The badge shows when the host that serves client.json is that verified domain or a subdomain of it (its www. only, for a site set to This host only). If the declaration is wrong, the domain's record goes missing, or the crown changes hands, sign-in keeps working and the screen goes back to "Published by". A registered client earns the same badge by its crown setting.
AI apps and MCP clients
AI apps such as Claude, ChatGPT, Codex and Cursor connect to the Chain Daddy MCP server through this same sign-in. Nothing needs registering: the user adds the server URL to the app, and the app finds everything else.
| MCP server | https://api.chaindaddy.io/mcp |
| Resource metadata (RFC 9728) | https://api.chaindaddy.io/.well-known/oauth-protected-resource/mcp |
| Authorization server metadata (RFC 8414) | https://api.chaindaddy.io/.well-known/oauth-authorization-server |
| Registration (RFC 7591), only for apps with no client metadata document | https://api.chaindaddy.io/api/v2/oauth/register |
| Revocation (RFC 7009) | https://api.chaindaddy.io/api/v2/oauth/revoke |
- Scopes.
api:readlets the app see the user's own account.api:writealso lets it make changes that move no money: the profile, alert rules, and messages to holders. No scope lets an app pay, trade, mint, launch, or sign anything with the user's wallet. - Client ID. The app's
client_idcan be the https URL of its client metadata document. Chain Daddy fetches it, under the same rules as aclient.json, and matchesredirect_uriagainst it exactly. A loopback redirect (http://127.0.0.1orhttp://localhost) matches on any port, and only connects the app to the MCP server: a site signs people in only to anhttpsredirect on its document's own origin. An app without a document can register as a public client (token_endpoint_auth_method: none) whose redirects return to the user's device: a loopback or the app's own scheme. https redirects are dropped from a registration, so a web-hosted app publishes a client metadata document instead. A registration that is never used expires after a day. - Tokens. Send
resource=https://api.chaindaddy.io/mcpand PKCE (S256) as usual. The app gets a one-hour access token that only the MCP server accepts, and a 30-day refresh token that changes every time it is used. Use each refresh token once: presenting a used one, or a used code, ends the connection. - Consent. The user is always asked. The consent screen shows the host that publishes the app's document and the app's name, and lists what the app can do. The user can disconnect it any time in Settings → Connected sites, which cuts it off at once.
In a frame on a coin page
When a coin page shows a page of your server as an app's view (an app whose manifest declares network.ownerOrigin.frame, such as the Community Wall), the page signs its visitor in to your frame, so someone already signed in to Chain Daddy doesn't sign in again:
- While it is signed out, your framed page posts
{type: 'cd-app:auth-request', nonce, clientId, scope}towindow.parent. Derive the nonce from a secret you keep for this page load, for examplebase64url(SHA-256(secret)).clientIddefaults to<your origin>/client.jsonandscopetoopenid. - If the visitor is signed in and has approved your site before, the page answers at once with
{type: 'cd-app:auth', nonce, idToken}, posted to your origin only. If they haven't, the page shows its own bar over your frame, "Continue as 0x12…ab", listing what your site gets, and answers when they tap it. While that bar is up the page posts{type: 'cd-app:auth-consent', nonce, open: true}, andopen: falsewhen it goes without a sign-in: hide your own Sign in button while it is open, so the visitor sees one way in. Otherwise nothing comes, so keep your own Sign in button. - Verify the ID token as you verify one from the token endpoint: its signature against the keys,
iss,aud(your client id),typsignin+v1,expand thenonceyou sent. Use itsjtionce. Then start your own session. {type: 'cd-app:auth', idToken: null}means the visitor signed out of Chain Daddy or switched wallets. End the session you started from the page's token, and ask again.
Take these messages only from window.parent, and only from origins your frame-ancestors allows. The page answers only for the server that page frames for that app, and only for a client that signs people in to that server's own origin. The Community Wall does all of this already.
Crown Apps don't use this
Inside your Crown App, keep using the host's session-token action: an app-session+v1 token verified against /api/v2/apps/jwks. If your site and your Crown App share a server, verify both kinds and key people by wallet: the app token's sub, and the sign-in token's wallets[0] (its sub is the wallet only for an older client).
Rules the flow enforces
- PKCE (
S256) andstateare required on every request. The response carriesiss(RFC 9207); check it. - A
redirect_urithat is not registered exactly is refused on chaindaddy.io with an error. - A code lives 60 seconds and redeems once. A code presented with the wrong client, redirect URI or verifier is burned.
- ID tokens and access tokens last five minutes. The access token is for
userinfo. prompt=noneshows nothing. A visitor signed in to Chain Daddy who has approved your site for what you ask comes back with a code at once. Anyone else comes back witherror=login_required(not signed in) orerror=consent_required(not approved, or approved for less), plus yourstateandiss. Send them through the usual flow when they choose to sign in.autoon the button does this for you.
Limits
| What | Limit |
|---|---|
| Everything one address sends without an API key: token, userinfo, keys and public reads together | 400 in any one minute |
| Token endpoint | 300 a minute per calling address, and 120 a minute for one person at one site |
| Approving a sign-in | 30 a minute per person |
| A coin page signing someone in to a framed server | 60 a minute per person |
| The consent page's lookup of your site | 120 a minute per address |
| Keyless holdings read | 60 a minute per caller |
| AI app registration | 20 an hour per address |
Past a limit the answer is 429 with Retry-After: 60; from the sign-in endpoints its body is {"error":"temporarily_unavailable"}.
A normal site stays well under these:
- Verify ID tokens on your own server. Checking a signature calls nobody.
- Cache the keys. The keys answer is good for five minutes (
Cache-Control: public, max-age=300). A library that refetches on an unknown key id (jose'screateRemoteJWKSet, openid-client) needs nothing more: create it once, not on every request. - Call the token endpoint once per sign-in, and do not call
userinfofor what the ID token already says.
Ask support if your site needs more.