Sign in with OpenWallet for any app
No registration and no API key. Publish one small JSON file on your site and send people to OpenWallet with OpenID Connect and PKCE.
OpenWallet is an OpenID Connect provider at https://wallet.openapps.network. Any app can use it. Your client_id is the https address of a JSON file on your own site that describes your app, a client metadata document: the same model as atproto OAuth and MCP authorization use (draft-ietf-oauth-client-id-metadata-document). The person approves in the OpenWallet extension with one click; there are no codes to type.
1. Publish your client metadata document
Serve this at the exact address you use as client_id, for example https://yourapp.example/oauth/client.json, with Content-Type: application/json:
{
"client_id": "https://yourapp.example/oauth/client.json",
"client_name": "Your App",
"client_uri": "https://yourapp.example/",
"redirect_uris": ["https://yourapp.example/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"],
"scope": "openid email"
}
| Rule | Why |
|---|---|
client_id is https, port 443, has a path, no query or fragment, and equals the address the file is served from. | The address is your app's identity. |
Every redirect_uris entry is https on the same host as the client_id, and is matched exactly. | A sign-in can only return to your own site. |
token_endpoint_auth_method is none: a public client, with PKCE S256 required. | No secrets to leak or rotate. |
| At most 32 KB, served directly (no redirects), from a public address, within 5 seconds. | OpenWallet fetches it at sign-in time, and caches it as your Cache-Control: max-age says (5 minutes to 24 hours). |
client_name is at most 60 characters, without control or direction-changing characters. | People see your host name first, and your name as “calls itself …”. |
A desktop app may list http://127.0.0.1/… or http://[::1]/… and set application_type: native; any port is then accepted. | RFC 8252 loopback redirects. People are told it is a local app on their computer. |
2. Sign in from the browser (PKCE)
const ISSUER = "https://wallet.openapps.network";
const CLIENT_ID = "https://yourapp.example/oauth/client.json";
const REDIRECT_URI = "https://yourapp.example/callback";
const b64u = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const random = () => b64u(crypto.getRandomValues(new Uint8Array(32)));
// On "Sign in with OpenWallet":
export async function signIn() {
const verifier = random(), state = random(), nonce = random();
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
sessionStorage.setItem("openwallet", JSON.stringify({ verifier, state, nonce }));
const url = new URL(ISSUER + "/oidc/authorize");
url.search = new URLSearchParams({
response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI,
scope: "openid email", state, nonce,
code_challenge: b64u(new Uint8Array(digest)), code_challenge_method: "S256",
});
location.assign(url);
}
// On your callback page:
export async function handleCallback() {
const p = new URLSearchParams(location.search);
const saved = JSON.parse(sessionStorage.getItem("openwallet") || "null");
sessionStorage.removeItem("openwallet");
if (!saved || p.get("state") !== saved.state || p.get("iss") !== ISSUER) throw new Error("Start again");
if (p.get("error")) throw new Error(p.get("error")); // access_denied: the person declined
const res = await fetch(ISSUER + "/oidc/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "authorization_code", code: p.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);
// Verify tokens.id_token (ES256, keys at ISSUER + "/oidc/jwks.json") before you trust it:
// iss === ISSUER, aud === CLIENT_ID, nonce === saved.nonce, exp in the future.
return tokens.id_token;
}
Check the signature of the ID token where it matters, on your server, with any JOSE library and the keys at /oidc/jwks.json. The discovery document is at /.well-known/openid-configuration and says client_id_metadata_document_supported: true.
What you receive
sub: a private id for this person on your website. Every app whoseclient_idis on the same registrable domain (for examplemail.yourapp.exampleandcalendar.yourapp.example) gets the samesub; other websites get a different one.emailandemail_verified, only if you ask for theemailscope and the person ticks it.xrpl_address(scopexrpl): an XRP Ledger address the person chose. Their device vouches for it, not the ledger: never use it alone to move value.nostr_npub(scopenostr), if the person published one.
ID tokens last 5 minutes; there are no refresh tokens. The audience (aud) is your client_id.
What the person sees
Your host name in bold, the name you give yourself as "calls itself …", where the sign-in returns to and what you ask for, then one button, Approve in OpenWallet. A warning appears for hosts with international characters and for local apps. Apps that OpenWallet registered directly are marked "Verified by OpenWallet"; that is the only difference, and you do not need it to use Sign in with OpenWallet.