The guide
Standard OpenID Connect, at a strict security profile (FAPI 2.0). Your server talks to login.333.eco; there is no password or shared secret to store.
Start here
1. Add your app under a business, and give the address of your server's public key (a JWKS). 2. We review it. 3. Use your client ID with the code below.
Everything your library needs is at login.333.eco/.well-known/openid-configuration.
Log in, or Connect?
Two button labels, one flow. Pick by where the button sits; your code and our page are the same either way, and our page always says Log in.
| Label | Use it when |
|---|
| Log in with HeartBank® | The person is entering your app, and HeartBank® is how they get in. |
| Connect HeartBank® | They are already inside your app, on your own log-in, and are adding HeartBank® to that account, for example to show the passkey mark. |
With Connect you tie the sub to the account they're already in, and you keep your own log-in.
What your library must support
| Feature | Required value |
|---|
| Flow | Authorization code, scope openid (add poh for the proof-of-personhood layers) |
| Pushed authorization requests (PAR) | Required |
| PKCE | S256 |
| Client authentication | private_key_jwt, ES256 |
| Token binding (DPoP) | Required, ES256 |
| ID token | ES256 |
| Refresh tokens | None: log the person in again, or keep your own session |
Which libraries work
An ordinary OpenID library works if it has these features, and many older ones lack PAR or DPoP. We list only what we have run against the real provider:
| Stack | Library | Result |
|---|
| Node.js | openid-client 6.8.9 | Works with no extra code |
| Python | Authlib 1.9.2 | Works; you write PAR and DPoP by hand (full sample) |
| Firebase | @333eco/firebase-bridge | Turns the ID token into your own custom token (below) |
Node.js
import * as oc from "openid-client"; // openid-client 6.8.9: everything below is built in
const config = await oc.discovery(new URL("https://login.333.eco"), CLIENT_ID,
{ id_token_signed_response_alg: "ES256", token_endpoint_auth_signing_alg: "ES256" },
oc.PrivateKeyJwt({ key: privateKey, kid: "your-key-id" }));
// 1. Send the person to log in. Keep verifier, nonce and state in your session.
const verifier = oc.randomPKCECodeVerifier(), nonce = oc.randomNonce(), state = oc.randomState();
const url = await oc.buildAuthorizationUrlWithPAR(config, {
redirect_uri: REDIRECT_URI, scope: "openid", nonce, state,
code_challenge: await oc.calculatePKCECodeChallenge(verifier), code_challenge_method: "S256",
});
redirect(url);
// 2. They come back to REDIRECT_URI.
const DPoP = oc.getDPoPHandle(config, await oc.randomDPoPKeyPair("ES256"));
const tokens = await oc.authorizationCodeGrant(config, callbackUrl,
{ pkceCodeVerifier: verifier, expectedNonce: nonce, expectedState: state, idTokenExpected: true },
undefined, { DPoP });
const { sub, amr } = tokens.claims();
Python
#!/usr/bin/env python3
"""Log in with HeartBank® from Python, with Authlib (served at /developers/docs/python_authlib.py; test/e2e.test.mjs runs this exact file against login).
Authlib builds PKCE and the private_key_jwt assertion; it has no PAR or DPoP helper, so those are written out here.
Reads ISSUER, CLIENT_ID, REDIRECT_URI, CLIENT_JWK (private, JSON) from the environment. Prints the authorization URL, reads the
callback URL back on stdin, verifies the ID token, and prints its claims as JSON."""
import json, os, secrets, sys, time, uuid
from urllib.parse import parse_qs, urlparse
import requests
from authlib.common.security import generate_token
from authlib.jose import JsonWebKey, jwt
from authlib.oauth2.rfc7636 import create_s256_code_challenge
ISSUER, CLIENT_ID, REDIRECT = os.environ["ISSUER"], os.environ["CLIENT_ID"], os.environ["REDIRECT_URI"]
BASE = os.environ.get("BASE_URL", ISSUER) # only the test harness sets this; in real use it is the issuer
HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
client_key = json.loads(os.environ["CLIENT_JWK"])
here = lambda url: url.replace(ISSUER, BASE)
meta = requests.get(here(f"{ISSUER}/.well-known/openid-configuration"), headers=HEADERS).json()
def client_assertion():
now = int(time.time())
return jwt.encode({"alg": "ES256", "kid": client_key["kid"]},
{"iss": CLIENT_ID, "sub": CLIENT_ID, "aud": meta["issuer"], "jti": uuid.uuid4().hex, "iat": now, "exp": now + 60},
client_key).decode()
def auth_fields():
return {"client_id": CLIENT_ID, "client_assertion": client_assertion(),
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"}
# PAR: the request goes server to server, the browser carries only a reference.
verifier, nonce, state = generate_token(64), secrets.token_urlsafe(16), secrets.token_urlsafe(16)
par = requests.post(here(meta["pushed_authorization_request_endpoint"]), headers=HEADERS, data={
**auth_fields(), "response_type": "code", "scope": "openid", "redirect_uri": REDIRECT, "nonce": nonce, "state": state,
"code_challenge": create_s256_code_challenge(verifier), "code_challenge_method": "S256"})
par.raise_for_status()
print(f'{meta["authorization_endpoint"]}?client_id={CLIENT_ID}&request_uri={requests.utils.quote(par.json()["request_uri"], safe="")}', flush=True)
# ...the person logs in, and the browser comes back to REDIRECT with code, state and iss.
back = parse_qs(urlparse(sys.stdin.readline().strip()).query)
assert back["state"][0] == state and back["iss"][0] == meta["issuer"], "mix-up or forgery"
# DPoP: a proof signed by a key this app holds, naming this request.
dpop_key = JsonWebKey.generate_key("EC", "P-256", is_private=True)
dpop = jwt.encode({"alg": "ES256", "typ": "dpop+jwt", "jwk": dpop_key.as_dict(is_private=False)},
{"htm": "POST", "htu": meta["token_endpoint"], "jti": uuid.uuid4().hex, "iat": int(time.time())}, dpop_key).decode()
tok = requests.post(here(meta["token_endpoint"]), headers={**HEADERS, "DPoP": dpop}, data={
**auth_fields(), "grant_type": "authorization_code", "code": back["code"][0], "redirect_uri": REDIRECT, "code_verifier": verifier})
tok.raise_for_status()
keys = JsonWebKey.import_key_set(requests.get(here(meta["jwks_uri"]), headers=HEADERS).json())
claims = jwt.decode(tok.json()["id_token"], keys, claims_options={"iss": {"essential": True, "value": meta["issuer"]},
"aud": {"essential": True, "value": CLIENT_ID},
"nonce": {"essential": True, "value": nonce}})
claims.validate()
print(json.dumps({"sub": claims["sub"], "amr": claims["amr"], "token_type": tok.json()["token_type"]}), flush=True)
Firebase
Only Firebase apps need this. After any library above gives you the ID token, your function exchanges it for a custom token in your project, with your service account.
import { getAuth } from "firebase-admin/auth";
import { createBridge } from "@333eco/firebase-bridge";
const bridge = createBridge({ issuer: "https://login.333.eco", clientId: CLIENT_ID, auth: getAuth(), requirePasskey: false });
const { customToken, passkey } = await bridge.exchange(idToken, { nonce });
// the browser calls signInWithCustomToken(auth, customToken)
Your rules get hb_amr and hb_passkey. The Firebase uid is hb:<sub>.
What the ID token tells you
| Claim | Meaning |
|---|
sub | This person, at your app only. Another app gets a different value, so two apps can't link a person through it. |
amr | How this log-in happened: swk or hwk = a passkey · otp, email = an email code · sms = a text code. A first-time log-in carries the passkey they just made. |
auth_time | When they last actually logged in, which can be long ago with single sign-on. If you need a recent log-in, send max_age. |
poh | Only with the poh scope and the person's consent: the layers they released, today ["passkey"]. Absent otherwise, never false. What it means. |
Require a passkey where it matters by checking amr. A person can link email, phone and passkeys into one account, so one sub can log in several ways; poh describes the account and amr describes this log-in.
A sub can stop appearing. If someone joins two accounts, the joined account's sub ends, and they reach you with the other one as a new user. We can't tell you the two are one person, because that is what per-app identifiers prevent. Offer your own way to move their data.
Logging out
Register a backchannel_logout_uri and we POST a signed logout token when their HeartBank® session ends. To log them out from your app, send them to https://login.333.eco/session/end.
Checking the status later
Layers can change after log-in. Register a change address and we POST a signed event (application/secevent+jwt) that says only that something changed. Then ask:
POST https://login.333.eco/poh
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<a private_key_jwt with aud https://login.333.eco/poh>
sub=<the person's sub>
200 {"poh":["passkey"],"poh_updated_at":1793000000}
404 no grant from this person to your app
429 once per person per 24 hours
The person can see every lookup, and can disconnect your app at any time.
Rules
Developer terms · Badge kit
Log in with HeartBank®: reference
Prove who someone is, with a passkey. OpenID Connect on the FAPI 2.0 profile.
login.333.eco (repo 333/333.eco/login.333.eco; its live discovery document is the authority: /.well-known/openid-configuration). Passkeys only, no passwords. ⭐ Each app sees its own ID for a person (pairwise sub), never one another app can match.
The buttons: Log in with HeartBank®, and Connect HeartBank® for a person adding HeartBank® to an account they already have in your app. Same flow, two labels. ⛔ Never "sign in": Log in authenticates, Sign with HeartBank® attests, and a log-in session can never sign.
The flow: push the request (POST /request, PAR, with PKCE S256 and your private_key_jwt), send the browser to /auth, exchange the code at /token (DPoP), read the claims. Log out ends the app's session AND the HeartBank® one: after your own sign-out, send the browser to /session/end.
Scopes: openid (sub, amr, locale) · poh, the verified-human badge (poh, poh_updated_at) · ledger (pid_token, for Thank with HeartBank®). Registration is at developers.333.eco/dashboard (an app under a business), reviewed by hand; self-serve registration by a document on your own site (log-in only, openid) was ruled 2026-11-11 and is not built yet.
GET https://login.333.eco/.well-known/openid-configuration
The discovery document: every endpoint, scope and algorithm
Who may call it: anyone, no key
Answers
200 OpenID Provider Metadata. object
On the MCP server: the tool discovery
GET https://login.333.eco/.well-known/jwks.json
The keys login signs ID tokens with
Who may call it: anyone, no key
Answers
200 A JWK set (ES256). object
On the MCP server: the tool jwks
POST https://login.333.eco/request
Push the log-in request (PAR, required)
Required before /auth. Add max_age=0 when a change needs a FRESH passkey log-in, and ui_locales (en or km) with the language the person chose in your app.
Who may call it: Sent as FORM fields, not a header: client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion = an ES256 JWT, iss = sub = your client id, aud = the endpoint's own address, one-use jti. Your keys live at your registered key URL.
Body application/x-www-form-urlencoded
| Field | Type | What it is |
|---|
client_id | string | |
client_assertion_type | string | |
client_assertion | string | |
response_type | "code" | |
scope | string | openid, plus poh or ledger if your app was approved for them. |
redirect_uri | string | |
state | string | |
nonce | string | |
code_challenge | string | |
code_challenge_method | "S256" | |
max_age optional | integer | |
ui_locales optional | "en" | "km" | |
Answers
201 The request is held. { request_uri, expires_in }400 A bad request. { error, error_description }401 Your client assertion was refused. { error, error_description }
What a success answer holds
| Field | Type | What it is |
|---|
request_uri | string | |
expires_in | integer | |
On the MCP server: not a tool: needs the app's own private key on its server; ⛔ the MCP server never holds or passes a developer's key
GET https://login.333.eco/auth
Send the browser here (one tap from where the person is)
The person presses their passkey (straight to it when the last log-in in this browser was a passkey; "Other ways to log in" brings the list back). login then returns the browser to your redirect_uri with code, state and iss.
Who may call it: anyone, no key
Parameters
| Name | Type | What it is |
|---|
client_id (query) | string | |
request_uri (query) | string | |
Answers
303 Back to your redirect_uri with code, state and iss.
On the MCP server: not a tool: a browser step: the person's own passkey
POST https://login.333.eco/token
Exchange the code
Who may call it: Sent as FORM fields, not a header: client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion = an ES256 JWT, iss = sub = your client id, aud = the endpoint's own address, one-use jti. Your keys live at your registered key URL. · A DPoP proof (ES256), with the access token as Authorization: DPoP <token>.
Body application/x-www-form-urlencoded
| Field | Type | What it is |
|---|
grant_type | "authorization_code" | |
code | string | |
redirect_uri | string | |
code_verifier | string | |
client_assertion_type | string | |
client_assertion | string | |
Answers
200 Tokens. { access_token, token_type, id_token, expires_in }400 Refused. { error, error_description }
What a success answer holds
| Field | Type | What it is |
|---|
access_token | string | |
token_type | "DPoP" | |
id_token | string | ES256; check iss, aud, nonce, auth_time (for a fresh log-in, on your server). |
expires_in optional | integer | |
On the MCP server: not a tool: needs the app's own private key on its server; ⛔ the MCP server never holds or passes a developer's key
GET https://login.333.eco/me
The person's claims
Who may call it: A DPoP proof (ES256), with the access token as Authorization: DPoP <token>.
Answers
200 Claims for the scopes granted. { sub, amr, locale, poh, poh_updated_at, pid_token }
What a success answer holds
| Field | Type | What it is |
|---|
sub optional | string | Pairwise: this person's ID in YOUR app only. |
amr optional | string[] | How they logged in, for example a passkey with the user verified. |
locale optional | "en" | "km" | |
poh optional | string[] | Scope poh: the verified-human layers this person has, when they choose to show them. |
poh_updated_at optional | integer | |
pid_token optional | string | Scope ledger: the person's token for Thank with HeartBank®, opaque to your app. |
On the MCP server: not a tool: needs a person's token; ⛔ an agent never holds one
POST https://login.333.eco/token/revocation
Revoke a token
Who may call it: Sent as FORM fields, not a header: client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion = an ES256 JWT, iss = sub = your client id, aud = the endpoint's own address, one-use jti. Your keys live at your registered key URL.
Body application/x-www-form-urlencoded
| Field | Type | What it is |
|---|
token | string | |
Answers
200 Revoked (or already gone).
On the MCP server: not a tool: needs the app's own private key on its server; ⛔ the MCP server never holds or passes a developer's key
GET https://login.333.eco/session/end
Log out of HeartBank® too
After your app's own sign-out, send the browser here. ⛔ Ending only your app's session leaves the HeartBank® one alive: the next Log in would come straight back with no passkey press, and on a shared phone the next person to tap it becomes the last one. login tells every app by back-channel, then returns to your root.
Who may call it: anyone, no key
Parameters
| Name | Type | What it is |
|---|
client_id (query) | string | |
post_logout_redirect_uri (query) | string | Your app's root, as registered. |
Answers
303 Back to your app's front page.
On the MCP server: not a tool: a browser step
GET https://login.333.eco/session/state
Is this browser logged in at HeartBank®? (display only)
For a header's Log in / Log out button, from listed origins only (CORS). ⛔ Display only: never trust it for anything.
Who may call it: anyone, no key
Answers
200 One yes-or-no. { loggedIn }403 Not a listed origin.
What a success answer holds
| Field | Type | What it is |
|---|
loggedIn | boolean | |
On the MCP server: not a tool: a browser cookie, display only
POST https://login.333.eco/notice
Announce a security change before making it
Your app sends only the person's pairwise sub and a kind (⛔ never a name); login emails each address the person linked, in their language, or shows it at their next log-in. ⚠️ If the notice can't go out, the change is not made. First-party apps only today. aud = https://login.333.eco/notice.
Who may call it: Sent as FORM fields, not a header: client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion = an ES256 JWT, iss = sub = your client id, aud = the endpoint's own address, one-use jti. Your keys live at your registered key URL.
Body application/x-www-form-urlencoded
| Field | Type | What it is |
|---|
client_assertion_type | string | |
client_assertion | string | |
sub | string | |
kind | "rails" | rails: the person's ways to receive money changed. |
lang optional | "en" | "km" | |
Answers
200 Sent. { ok }400 A bad request. { error, error_description }401 Your client assertion was refused. { error, error_description }403 Your app may not send notices. { error, error_description }502 Not delivered: do not make the change. { error, error_description }
What a success answer holds
| Field | Type | What it is |
|---|
ok optional | true | |
On the MCP server: not a tool: needs the app's own private key on its server; ⛔ the MCP server never holds or passes a developer's key
POST https://login.333.eco/poh
Read a person's verified-human layers again
For a person who granted your app poh. ⛔ A person who didn't, and a person who doesn't exist, get the SAME 404: no answer tells you which. aud = https://login.333.eco/poh.
Who may call it: Sent as FORM fields, not a header: client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion = an ES256 JWT, iss = sub = your client id, aud = the endpoint's own address, one-use jti. Your keys live at your registered key URL.
Body application/x-www-form-urlencoded
| Field | Type | What it is |
|---|
client_assertion_type | string | |
client_assertion | string | |
sub | string | |
Answers
200 The layers, when there are any. { poh, poh_updated_at }401 Your client assertion was refused.404 Not found (see above).429 Too often: try again tomorrow.
What a success answer holds
| Field | Type | What it is |
|---|
poh optional | string[] | |
poh_updated_at | integer | |
On the MCP server: not a tool: needs the app's own private key on its server; ⛔ the MCP server never holds or passes a developer's key
POST https://login.333.eco/press/wrap
Thank with HeartBank®: exchange a press for a pressToken
Step 3 of a thank: the press result the browser brought back from sign.333.eco, exchanged by your server. ⛔ No log-in history: a press is not a log-in. Ledger writers only.
Who may call it: anyone, no key
Body application/x-www-form-urlencoded
| Field | Type | What it is |
|---|
result | string | |
Answers
200 The pressToken for POST /v1/commit. { pressToken }400 The result was refused. { error, error_description }403 Not a ledger writer. { error, error_description }
What a success answer holds
| Field | Type | What it is |
|---|
pressToken | string | |
On the MCP server: not a tool: the app's server, after the person's own press