TamgaNetwork

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Tamga Wallet

example.com wants to access:

Full name shared
Over 18 proven
Email shared
Home address hidden
Allow
Deny

No password shared · you control every field

What a site can — and cannot — get

A site receives only the fields you approve. There is no password to steal. It cannot ask beyond its registered scope: every relying party is listed in the trust list with the fields it may request, and your wallet warns on anything more. Your account key is a pseudonym only for that site: another site sees a different one, so sites cannot match you. It is derived from your verified identity, so on a new phone you get the same pseudonyms back once you verify your identity again. Your ID number and document number are never sent.

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 };
}
Passkey (WebAuthn)
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 localhost or 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

Working end to end on our sample site (sign-up, repeat-use rejection, sign-in, passkey). The hosted verifier hands results only to the site that opened the presentation, proven with a signature from its trust-list key, and releases the values once. Each site gets its own pseudonym from the wallet; the verifier checks it with the wallet instance attestation.