Sign in with Send 🔑

Let people sign in to your app or game with their Send account, and bring their handle, Send ID, Send Power and Send Boost with them: the same account in every app built around Send. Each member ticks what your app may see, and nothing else leaves.

Run by this site’s own server, not by a blockchain. What your app receives is signed with published keys, so you can check it without asking us again.

Register an app See the example Get the button

How it works

  1. Register your app below: its name, its homepage and the exact addresses members are sent back to. You get a client ID at once, and a client secret too if your app runs on a server.
  2. Send people to Sign in with Send. Your app opens /oauth/authorize with the scopes it wants, a state and a PKCE challenge. The member signs in here the way they always do, two-factor included, and sees your app’s name, its website and the address they will be sent back to, with one tick-box for each thing you asked for. Every box starts unticked. The first time a member lets your app in, they also confirm it’s them (their password, two-factor step or wallet), as for any security change here.
  3. They come back with a one-time code. Your app swaps it at /oauth/token for an ID token (a signed JWT) and an access token. Nothing reaches your app before they press Allow, and only what they ticked.
  4. Keep it current. Send Power, level and Boost move. /oauth/userinfo gives you today’s values, and when the member ticks it, a refresh token keeps them up to date for 30 days without a new sign-in.

It is standard OAuth 2.0 (the authorization code flow with PKCE) with OpenID Connect on top, so an OpenID Connect library signs people in from the discovery address under Endpoints, as long as it accepts ID tokens signed with ES256, the only algorithm used here. It has been tried with openid-client 6 and 5, oidc-client-ts 3 and Auth.js (@auth/core). A library that expects RS256 unless told otherwise needs telling: in openid-client 5 and next-auth 4, set id_token_signed_response_alg: 'ES256' on the client (next-auth: client: { id_token_signed_response_alg: 'ES256' }); in Spring Security, give the decoder a JWS algorithm resolver that answers ES256.

The privacy promise

  • Your app gets only what the member ticks. Every box starts unticked, and an unticked box is not shared: not in the ID token, not from userinfo.
  • A private ID for each app. The sub your app receives is different in every app, so two apps cannot match a member up by it. It is made at random the first time the member lets your app in and kept, so it stays the same for your app, revokes included, until the account or your app is deleted. The Send ID, which is the same everywhere, is shared only when the member ticks it, and its box says so. auth_time is sent only when you ask how recent the sign-in is (max_age or prompt=login), and then it is worked out from your request, never read off the member’s session: the earliest time your max_age allows (never later than the real sign-in), or the exact second of a fresh proof the member made for your request. So it cannot match a member up either. One honest limit: max_age is weighed against the member’s last sign-in or proof floored to ten minutes, so a reviewed app that sends silent prompt=none requests with different max_age values can narrow that time down to ten minutes.
  • Never shared with any app: email, wallets, IP address and sign-in history. No scope asks for them. Holdings, OG tier and communities are not sent as such either, but the Send Boost is made of them: it grows with how much $SEND and $GWC a member’s wallets hold (and for how long), with their OG tier, with being in a live community and with Rocket Run, weekly and beta boosts. So an app given the Boost can estimate a member’s holdings from it and see when any of these change, and the member’s box says so.
  • Some of what members tick is public anyway. A member’s exact Send Power and profile picture are shown next to their handle on their public Send Wall and on the leaderboards, so an app given either can find the handle and recognise them across apps, as the Send ID does. Send IDs go in the order members joined, and when each member joined is public on their Wall, so the Send ID can narrow down the handle too (or give it at once, for a member who made their Send ticket public). Their boxes say so.
  • What this site keeps: your app’s registration; for each member who allowed it, what they ticked, when they first allowed it and when they last changed that, until they revoke it; the private ID your app knows them by; while your app is signed in, its tokens, each stored as a hash and deleted when it expires (a used refresh token is not kept: each one names its sign-in, so an old one presented again is still caught); and the signing keys, their private halves encrypted.
  • What it does not keep: any record of when or how often anyone uses your app; your userinfo calls; IP addresses or devices, beyond the rate-limit counters the whole site keeps in memory for up to about an hour.
  • Nothing from anywhere else on the sign-in pages. The pages under /oauth/ load no analytics, no beacon and nothing from another site, cannot be put in a frame, and are never cached.
  • Members stay in charge. Settings, under Apps you’ve signed in to, lists every app, what it can see and since when. Revoke ends its tokens at once. Changing the password, ending other sessions or turning two-factor off signs the member out of every app too, and the next sign-in asks again. Deleting the account revokes everything, and an account put in read-only mode is signed out of every app until it lifts.
  • Checkable without asking us again. The ID token is signed (ES256) with a key published at /.well-known/jwks.json, so your server checks it on its own: a Send passport, in effect. A new sign-in needs this site to be up; a token you have already checked stays checked.

What an app can ask for

Ask for the scopes in scope, separated by spaces. A scope this site does not know (email, profile and the like) is left out rather than refused, and the token answer’s scope says what was granted. Ask for what you need: a short list is an easy one to tick.

ScopeClaims it addsWhat the member is shownExample
openid alwayssubA private ID for them in your app alone: different in every app"q2C1vYw0LrX8nS4eKpQ7tZ3mB9dF6hJ1aW5cE0gU2yI"
send.handlepreferred_username, profileTheir @handle and the link to their public Send Wall"alice", "https://sendrh.com/u/alice"
send.picturepictureThe address of their public profile picture (left out when they have none), with a note that it is next to their handle on their public Send Wall, so it finds the handle"https://sendrh.com/uploads/a1b2c3.webp"
send.idsend_idTheir Send ID: the same number in every app they share it with, so apps can recognise them across each other. Send IDs go in the order members joined, and join dates are public on Send Walls, so it can narrow down or find the handle12
send.powersend_power, send_level, send_titleTheir Send Power points, the level they make and its title, with a note that the exact points are public next to their handle on their Send Wall, so they find the handle15200, 31, "Send Sergeant"
send.boostsend_boostThe total multiplier every point of theirs is paid at, as one number, with a note that it is made of their holdings, OG tier, communities and boosts, so an app can estimate their holdings from it3.5
send.verifiedsend_verifiedWhether a wallet of theirs passed the holder check: not which wallet, not how much it holdstrue
offline_accessnone: a refresh tokenKeeping the values above up to date for 30 days without a new sign-in"refresh_token" in the token answer

The same claims, with the same values, are in the ID token and in userinfo. The ID token holds them as they were when it was issued; userinfo holds them as they are now. The picture is served so that your own pages can show it: an <img> on your site loads it.

Endpoints

The issuer is exactly https://sendrh.com. A library needs only the discovery address; the rest are listed in it.

WhatAddress
DiscoveryGET https://sendrh.com/.well-known/openid-configuration
Signing keys (JWKS)GET https://sendrh.com/.well-known/jwks.json
Sign-in pageGET https://sendrh.com/oauth/authorize
TokenPOST https://sendrh.com/oauth/token
UserinfoGET or POST https://sendrh.com/oauth/userinfo
RevokePOST https://sendrh.com/oauth/revoke
Introspection (your own tokens)POST https://sendrh.com/oauth/introspect
Handles check (reviewed server apps)POST https://sendrh.com/oauth/handles

The rules

  • response_type=code only. PKCE with code_challenge_method=S256 is required of every app; plain is refused. state is required (up to 2,048 characters) and comes back with the answer, together with iss. A nonce you send is put in the ID token.
  • redirect_uri is matched exactly, character for character: case, a trailing slash and the query all count. Register it in plain ASCII, an international domain in its xn-- form, exactly as your app sends it. A wrong address or an unknown app is shown an error page and sent nowhere.
  • A code works once, within 60 seconds, for the app, address and PKCE verifier it was made for. Using it a second time revokes every token issued from it.
  • An access token lasts an hour and an ID token ten minutes: stop using a token once its expires_in has passed, or when it is answered 401. A refresh token changes at every use: the one you sent stops working at once, and of that sign-in’s access tokens only the one just replaced keeps the rest of its hour. A used refresh token presented again revokes the whole chain, and the chain ends 30 days after the sign-in. One member keeps at most 20 live sign-ins in one app: a 21st ends one of the others, first one without a refresh token, then the one used longest ago (as far as its tokens show: an access token says when its sign-in was last exchanged or refreshed, for its hour).
  • Send /oauth/token and /oauth/revoke as application/x-www-form-urlencoded. A server app authenticates with client_secret_basic or client_secret_post; a browser or mobile app sends its client_id alone.
  • Token, userinfo, revoke, discovery and the keys answer any origin (CORS, never with cookies), so a browser app can call them directly.
  • prompt=none answers with a code, or login_required without showing anything: a member who is signed out, whose account cannot sign in to apps, or who has not allowed your app (for what you asked) is answered the same way, in the same words, so your app learns nothing before the member presses Allow; send them again without prompt=none. prompt=consent asks the member again, every box unticked. prompt=login, and a max_age the member’s sign-in is older than, ask for a fresh proof (or answer login_required with prompt=none); a member who has already answered for everything you asked is asked for the proof alone, with no boxes, and what they allowed stays as it was. prompt=select_account shows them which account they are signed in with, the same way. max_age is weighed against the last sign-in or proof floored to ten minutes, with a minute to spare, so one under about eleven minutes may ask for a fresh proof even right after one (it depends on where that proof fell in its ten minutes), and under a minute it always does; to force one, use prompt=login.
  • Until an admin has reviewed your app, an error is not sent to your address on its own: the member is shown it, with a link back to your address that carries the same error and state. That keeps this site from being a redirector anyone could aim. Once reviewed, errors (and prompt=none’s answers) come back at once.
  • Use a full-page redirect: once a member has allowed your app it comes straight back without showing a page. A pop-up works with a library that does not need window.opener (the pages under /oauth/ cut it). A hidden iframe with prompt=none never sees a member’s sign-in here (the site’s cookie is not sent to other sites’ frames). Once your app is reviewed it always answers login_required; until then it gets no answer at all, because the error is a page shown to the member and pages here cannot be framed, so a library waits for its own timeout. To stay up to date in the background, ask for offline_access and use the refresh token (oidc-client-ts: automaticSilentRenew only with a refresh token).
  • Your server trusts only what it checked itself: an ID token whose signature, iss and aud (your client ID) it verified, or an access token whose userinfo answer (or introspection) names your client ID in aud and client_id. Never trust claims or a token a browser hands over without that check: another app’s token for the same member would pass.
  • Errors are the standard OAuth ones, as JSON { error, error_description }: invalid_request (a missing grant_type included), invalid_client (401, always with a Basic challenge), invalid_grant, invalid_scope, unsupported_grant_type, unauthorized_client (403: the handles check, for an app that may not use it) and slow_down (429). At your redirect address: access_denied (the member pressed Cancel), login_required, invalid_request, invalid_scope, unsupported_response_type, request_not_supported and request_uri_not_supported. Write your code against the error value; the description is for people.
  • No one member can use up an app’s minute, and nor can anyone else. A browser app has no shared minute at all: each member has their own share in it, across all their tokens (30 token requests, 120 userinfo requests and 30 introspections a minute), one sign-in chain refreshes at most 10 times a minute and 120 an hour, and one token reads userinfo 120 times a minute. A server app that proves its secret has budgets of its own and is never counted against its address, so a server calling from one address is not held back by it: 600 exchanges and refreshes that go through, 3,000 userinfo requests with its live tokens and 600 introspections of them a minute, and 120 handle checks. What such an app sends that is refused (a made-up code relayed to its callback, a random token sent to its API) counts in a separate budget (3,000 token requests and 6,000 introspections a minute) that never holds back one that goes through. Any other request that carries nothing real counts against its address: 120 token requests or browser-app introspections a minute, and 4,000 userinfo requests with a token this site does not know (a token past its hour counts against its member’s share instead; a live one never counts against its address). Each address has a backstop of 3,000 token requests and introspections that did not prove a secret, 6,000 userinfo requests without a live token, and 120 sign-in pages in ten minutes. Five apps per member, five redirect addresses per app.
  • The handles check, for reviewed server apps that keep members’ handles theirs in your app too: POST /oauth/handles with your client authentication and { "handles": ["alice", "@bob"] } (up to 50) answers { handles: { alice: true, bob: false }, taken: ["alice"] }: yes or no for each, whatever the capitals, keyed by each handle in lower case without its @, and nothing more about anyone.

Example: a browser app, no library

A public app (no secret, because a browser cannot keep one; PKCE is its proof). Register it as public with the address of your callback page, put your client ID in, and call signInWithSend() from your button.

// Sign in with Send from a browser app: no library and no secret (PKCE is the proof)
const ISSUER = 'https://sendrh.com';
const CLIENT_ID = 'sapp_your_client_id';             // from Your apps, below
const REDIRECT_URI = location.origin + '/callback';  // registered exactly as written
const SCOPE = 'openid send.handle send.id send.power send.boost';
const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const random = () => b64url(crypto.getRandomValues(new Uint8Array(32)));

async function signInWithSend() {                    // call it from your button
  const verifier = random(), state = random();
  const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
  sessionStorage.setItem('send-signin', JSON.stringify({ verifier, state }));
  location.assign(ISSUER + '/oauth/authorize?' + new URLSearchParams({ response_type: 'code', client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI, scope: SCOPE, state, code_challenge: challenge, code_challenge_method: 'S256' }));
}

async function finishSignIn() {                      // run it on the page at REDIRECT_URI
  const q = new URLSearchParams(location.search), saved = JSON.parse(sessionStorage.getItem('send-signin') || '{}');
  sessionStorage.removeItem('send-signin');
  if (!saved.state || q.get('state') !== saved.state || q.get('iss') !== ISSUER) throw new Error('not the sign-in we started');
  if (q.get('error')) throw new Error(q.get('error'));   // access_denied: they pressed Cancel
  const res = await fetch(ISSUER + '/oauth/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'authorization_code',
    code: q.get('code'), redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: saved.verifier }) });
  const tokens = await res.json();
  if (!res.ok) throw new Error(tokens.error);
  const me = await fetch(ISSUER + '/oauth/userinfo', { headers: { Authorization: 'Bearer ' + tokens.access_token } });
  return me.json();   // { sub, aud, client_id, preferred_username, profile, send_id, send_power, ... }: what they ticked
}

The answer holds only the claims the member ticked: a box they left unticked is simply missing. Keep the access token in memory and ask userinfo again when you want fresh values; it lasts an hour.

A server, with an OpenID Connect library

A confidential app: register it as confidential and keep the secret on your server. Shown with openid-client 6 for Node; another OpenID Connect client works the same way from the discovery address once it accepts ES256 ID tokens (see How it works, above).

import * as client from 'openid-client';
const config = await client.discovery(new URL('https://sendrh.com'), 'sapp_your_client_id', 'ssec_your_client_secret', undefined,
  { execute: [client.enableNonRepudiationChecks] });   // so the ID token's signature is checked too, against the published keys

// 1. your "Sign in with Send" route
const verifier = client.randomPKCECodeVerifier(), state = client.randomState();
req.session.sendSignIn = { verifier, state };          // wherever your app keeps a sign-in in progress
res.redirect(client.buildAuthorizationUrl(config, { redirect_uri: 'https://your.app/callback', scope: 'openid send.handle send.id send.power',
  code_challenge: await client.calculatePKCECodeChallenge(verifier), code_challenge_method: 'S256', state }).href);

// 2. your callback route, at https://your.app/callback
const { verifier: v, state: s } = req.session.sendSignIn;
const tokens = await client.authorizationCodeGrant(config, new URL(req.url, 'https://your.app'), { pkceCodeVerifier: v, expectedState: s });
const claims = tokens.claims();   // the ID token, checked: signature (ES256, the published keys), issuer, audience, expiry
const fresh = await client.fetchUserInfo(config, tokens.access_token, claims.sub);   // today's Send Power, level and Boost

Checking an ID token yourself

No library needed: Node’s own crypto checks the signature against the published keys. This is all it takes to trust what an ID token says without asking this site.

import { createPublicKey, verify } from 'node:crypto';
const ISSUER = 'https://sendrh.com', CLIENT_ID = 'sapp_your_client_id';
let jwks = null;   // keep it, and fetch it again when a token names a key you do not have

async function checkIdToken(idToken, nonce) {
  const [h, p, s] = idToken.split('.');
  const head = JSON.parse(Buffer.from(h, 'base64url')), claims = JSON.parse(Buffer.from(p, 'base64url'));
  if (head.alg !== 'ES256') throw new Error('unexpected algorithm');   // the only one: never accept "none"
  if (!jwks || !jwks.keys.some((k) => k.kid === head.kid)) jwks = await (await fetch(ISSUER + '/.well-known/jwks.json')).json();
  const jwk = jwks.keys.find((k) => k.kid === head.kid);
  if (!jwk) throw new Error('unknown signing key');
  const key = createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, format: 'jwk' });
  if (!verify('sha256', Buffer.from(h + '.' + p), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(s, 'base64url'))) throw new Error('bad signature');
  const now = Math.floor(Date.now() / 1000);   // a minute of leeway for a clock that is a little out
  if (claims.iss !== ISSUER || claims.aud !== CLIENT_ID || claims.exp + 60 < now || claims.iat - 60 > now) throw new Error('not for this app, or expired');
  if (nonce !== undefined && claims.nonce !== nonce) throw new Error('wrong nonce');
  return claims;   // sub, and the claims the member ticked: preferred_username, send_id, send_power and the rest
}

The Sign in with Send button

Two colours, as SVG files or as HTML you paste in. The HTML version takes your page’s font size and is never less than 44 pixels tall. Point it at your own route that starts the sign-in, and keep the words “Sign in with Send” so members know what they are pressing.

The dark Sign in with Send button: a lime S in a circle, then the words, on near-black with a lime ring
Dark
The lime Sign in with Send button: a lime S on a black circle, then the words in black, on lime
Lime

As HTML, dark

<a href="https://your.app/sign-in-with-send" style="display:inline-flex;align-items:center;gap:0.6em;min-height:44px;padding:0.4em 1.1em 0.4em 0.6em;box-sizing:border-box;border:2px solid #c6f000;border-radius:999px;background:#0c0e09;color:#f2f6ec;font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-weight:700;line-height:1.2;text-decoration:none"><svg width="1.5em" height="1.5em" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="12" fill="#c6f000"/><path d="M16.2 8.2C15.4 6.9 13.9 6.3 12.2 6.3 9.9 6.3 8.2 7.5 8.2 9.3c0 1.9 1.8 2.5 3.8 2.9 2.2.4 4 1.1 4 3.1 0 1.8-1.8 2.9-4.1 2.9-1.9 0-3.5-.8-4.3-2.2" fill="none" stroke="#0c0e09" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>Sign in with Send</a>

As HTML, lime

<a href="https://your.app/sign-in-with-send" style="display:inline-flex;align-items:center;gap:0.6em;min-height:44px;padding:0.4em 1.1em 0.4em 0.6em;box-sizing:border-box;border:2px solid #020301;border-radius:999px;background:#c6f000;color:#020301;font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-weight:700;line-height:1.2;text-decoration:none"><svg width="1.5em" height="1.5em" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="12" fill="#020301"/><path d="M16.2 8.2C15.4 6.9 13.9 6.3 12.2 6.3 9.9 6.3 8.2 7.5 8.2 9.3c0 1.9 1.8 2.5 3.8 2.9 2.2.4 4 1.1 4 3.1 0 1.8-1.8 2.9-4.1 2.9-1.9 0-3.5-.8-4.3-2.2" fill="none" stroke="#c6f000" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>Sign in with Send</a>

Your apps

Any member can register up to five apps, free. An app works as soon as it is registered. Its sign-in page says “Not reviewed by Just Send It” until an admin has looked at it, and changing its name, homepage, description or addresses clears a review. Names, homepages and descriptions that pass for this site are refused: its names in any spelling, its address, its button’s and account’s words, staff words, and letters from other alphabets in a name. An admin can switch off an app that misleads people: its sign-ins and tokens stop at once, it stays exactly as it was (no edit, no new secret, no delete) until the admin decides, and the hosts it sent members to cannot be used by any other app, or by a name under them, until the admin lifts that — even if the app or its developer’s account is deleted. An app that already had one of those hosts keeps it. Changing an app’s redirect addresses or homepage, making it a new secret and deleting it each ask you to confirm it’s you, as any security change here does. Adding a redirect address on a new origin — a scheme, host or port the app did not have — signs every member out of the app, and each is asked again at their next sign-in, because what they allowed covered where the app sent them then. Another address on this device (http://localhost or 127.0.0.1, any port) does not, once the app has one.

Checking whether you are signed in.