How Tamga works
Sign in with Tamga
You already know “Sign in with Google”. “Sign in with Tamga” looks the same, but works the other way round: the identity lives in your wallet, the site gets only the fields you approve, and after the first time it gets nothing at all.
Two steps: sign up once, then a passkey
- Sign up (once). The site asks for a small set of fields — for example first name and last name — and gets its own pseudonym for you. On a computer you scan a QR code; on the phone the wallet opens directly. The wallet shows exactly what is asked and who is asking; you approve.
- The verifier checks the presentation — signature, trust list, revocation status and the device binding. The site receives the result on its own server and opens your account.
- Add a passkey. Right after sign-up the site offers “add a passkey to this device”. From then on you sign in with Face ID or a fingerprint: the wallet does not open and no field is shared. The passkey only works for that one site.
- New device, no passkey? “Sign in with Tamga” sends only your pseudonym for that site — no document field — then you add a passkey again.
Website
asks for fields
Tamga Wallet
you approve
Verifier
checks the proof
Passkey
daily sign-in
You approve, field by field
The site asks only for what it needs; fields you don’t approve are never sent. Some facts can be proven instead of revealed — “over 18” without the birth date.
example.com wants to access:
No password shared · you control every field
What a site can — and cannot — get
For developers
The page kit is @tamga-network/verifier/web (also served as a script by the verifier). It draws the QR code or the “open in wallet” button and reports the result — it does not make the decision: browser code can be changed by the user, so the decision is always made on your server.
/**
* Example 01 — "Sign up / Sign in with Tamga" on your website, using the hosted verifier.
*
* Your SERVER opens each presentation, proving who it is with a short-lived assertion signed by the key of
* your trust-list registration (ADR-0017). The page only shows the QR code and polls a status token; the
* values the person approved are handed to your server once.
*
* npm install @tamga-network/verifier
*/
import { createHmac } from "node:crypto";
import { createRpAssertion, type RpSigner } from "@tamga-network/verifier";
export interface SignInConfig {
/** The hosted verifier, e.g. "https://verify.tamga.network". */
verifier: string;
/** Your registration: pemRpSigner(KEY_PEM, CERT_PEM) — client_id is x509_hash of your access certificate (HAIP 1.0). */
rp: RpSigner;
/** Your own secret; the stored account key is a keyed hash of the site pseudonym. */
siteSecret: string;
fetch?: typeof fetch;
}
/** What your page needs to draw the QR code and poll the status. */
export interface StartedPresentation {
presentation_id: string;
qr_payload: string;
expires_at: string;
status_token: string;
}
export type SignInResult =
| { ok: true; accountKey: string; givenName?: string; familyName?: string }
| { ok: false; outcome: "PENDING" | "REJECTED" | "INDETERMINATE" | "ALREADY_USED" };
export function tamgaSignIn(cfg: SignInConfig) {
const f = cfg.fetch ?? fetch;
const auth = async () => ({ authorization: `Bearer ${await createRpAssertion(cfg.rp, cfg.verifier)}` });
/** POST /tamga/start → return this JSON to your page (TamgaVerifier.mount({ start })). */
async function start(policy: "site-signup" | "site-signin"): Promise<StartedPresentation> {
const r = await f(`${cfg.verifier}/presentations`, {
method: "POST",
headers: { ...(await auth()), "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ policy_id: policy }),
});
if (!r.ok) throw new Error(`verifier refused the request (${r.status})`);
return (await r.json()) as StartedPresentation;
}
/** POST /tamga/session → your page sends the presentation id after ACCEPTED; decide here, on the server. */
async function finish(presentationId: string): Promise<SignInResult> {
const id = encodeURIComponent(presentationId);
const result = (await (await f(`${cfg.verifier}/presentations/${id}`, { headers: await auth() })).json()) as {
outcome?: "ACCEPTED" | "REJECTED" | "INDETERMINATE";
};
if (result.outcome !== "ACCEPTED") return { ok: false, outcome: result.outcome ?? "PENDING" };
const r = await f(`${cfg.verifier}/presentations/${id}/claims`, { headers: await auth() });
if (r.status === 410) return { ok: false, outcome: "ALREADY_USED" }; // values are handed out once
const { claims } = (await r.json()) as { claims: Record<string, unknown> };
// ADR-0031: the account key is the wallet's pseudonym for YOUR site (another site sees a different one); the verifier
// has checked its signature, audience, nonce and the wallet instance attestation. No document value is sent.
if (typeof claims.pseudonym !== "string") return { ok: false, outcome: "REJECTED" };
const accountKey = createHmac("sha256", cfg.siteSecret).update(claims.pseudonym).digest("base64url");
return {
ok: true,
accountKey,
givenName: claims.given_name as string | undefined,
familyName: claims.family_name as string | undefined,
};
}
return { start, finish };
}import { passkey } from "@tamga-network/verifier/web";
// after sign-up, while the session is open:
const o = await post("/passkey/register/options");
await post("/passkey/register/verify", await passkey.create(o));
// daily sign-in — no wallet, no fields shared:
const { flow, options } = await post("/passkey/login/options");
await post("/passkey/login/verify", { flow, response: await passkey.get(options) });- One use: a presentation opens exactly one session.
- Three outcomes: ACCEPTED, REJECTED or INDETERMINATE. INDETERMINATE means “could not be checked right now — try again”, never “the document is invalid”.
- Cookies: HttpOnly, SameSite=Lax, Secure, server-side expiry; never log names, keys or passkey IDs.
- WebAuthn does not work on a bare IP address — use
localhostor an HTTPS domain.
Built on open standards
Not a proprietary login: presentations use OpenID for Verifiable Presentations (OpenID4VP) with SD-JWT VC credentials — the same profiles as the EU’s EUDI Wallet — and daily sign-in uses standard WebAuthn passkeys.
Status today