Skip to content

Sign in with Prson

A button and a code. The person scans it with the Prson app and approves. Your site gets an ID made for your site and only the facts you asked for. No passwords, and no fee per sign-in.

How it works

  1. Your page calls PrsonLogin.signIn(). The SDK shows a dialog with a code (on a phone, an "Open Prson" button).
  2. The person scans it, sees your domain and exactly what you ask for, and approves with Face ID or a fingerprint.
  3. The wallet sends a signed credential to your page over an end-to-end encrypted channel.
  4. Your page posts it to your server, which checks it with @prson/login-verifier.

Install

npm install @prson/login-sdk @prson/login-sdk-ui @prson/login-verifier

Source and changelog: SDKs and packages.

Your page

login.html
<prson-signin-button id="signin" variant="amber" size="lg"></prson-signin-button>
<script type="module">
  import "@prson/login-sdk-ui";
  import { PrsonLogin } from "@prson/login-sdk";

  document.querySelector("#signin").addEventListener("click", async () => {
    const result = await PrsonLogin.signIn({
      // A one-time nonce from YOUR server, fresh for each code shown.
      nonce: async () => (await fetch("/auth/prson/nonce", { method: "POST" }).then((r) => r.json())).nonce,
      required: {
        minVerificationLevel: 5,          // a passport or ID card chip checked by Prson
        predicates: ["age_gte_18"],       // facts: yes or no only
        requiredClaims: ["given_name"],   // claims: the value
      },
    });
    // Check it on your server; result.claims here is only a copy for the page.
    await fetch("/auth/prson", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ presentation: result.sdJwtVc, nonce: result.nonce }),
    });
  });
</script>

Your server

server.mjs
import { randomBytes } from "node:crypto";
import { verifyPresentation } from "@prson/login-verifier";

// 1. A nonce per code: keep it server side, use it once.
app.post("/auth/prson/nonce", (req, res) => {
  const nonce = randomBytes(16).toString("base64url");
  req.session.prsonNonce = nonce;
  res.json({ nonce });
});

// 2. Check the presentation.
app.post("/auth/prson", async (req, res) => {
  const expectedNonce = req.session.prsonNonce;
  delete req.session.prsonNonce;
  const result = await verifyPresentation(req.body.presentation, {
    expectedAudience: "https://shop.example.com",
    expectedNonce,
  });
  if (!result.ok) return res.status(401).json({ error: result.reason });
  // result.prsonId: the person's ID on your site. result.claims: what they shared.
  req.session.userId = await findOrCreateUser(result.prsonId, result.claims);
  res.json({ ok: true });
});

verifyPresentation checks Prson's signature, that the credential is bound to your domain and nonce, that it is fresh and not revoked. On success it returns { ok: true, prsonId, claims, disclosedClaimNames, credential }; on failure { ok: false, reason } (for example nonce_mismatch, aud_mismatch, vc_expired, vc_revoked).

The Prson ID

prsonId is the person's ID on your site: the same every time they sign in, different on every other site, so sites can't link people. Use it as the user id in your database. Show who signed in right after sign-in: whoever scans the code first signs in.

What you can ask for

OptionMeaning
nonceA one-time value from your server (or a function returning one). Required for a backend that trusts the result.
required.minVerificationLevel0 to 6, default 1. 5: a passport or ID card chip checked by Prson. 0 accepts unverified identities.
required.predicatesFact ids the person must meet, such as age_gte_18. Yes or no only.
required.requiredClaimsClaim names the person must share, such as given_name.
required.identityTypepersonal or organization.
required.uniqueHumanIdtrue: one verified person per account on your site (see below).
allowAnonymoustrue: also accept a sign-in with no identity, only a key for your site.
ephemeralIdentitytrue: a new key every sign-in, so visits can't be linked.
notificationstrue: offer the person to receive notifications from your site.
ttlMinutesHow long the credential lives before the session renews it.
ui{ mountQrInto, theme, getAppUrl }: draw the code in your page instead of the dialog.
returnUrlSame-phone flows: your app link to return to (https, your domain).
environment"production" (default) or "dev".

Facts (predicates)

required.predicates takes the same fact ids as verifications. The person can approve only when their identity can prove each one, and shares only yes or no. 13 facts and fact families work today; phone and address facts only where Prson can verify them.

Each one, with what it means and where it comes from: What you can ask for.

Claims

The credential always says how verified the person is. Other claims are shared only when you list them in required.requiredClaims and the person turns them on. Document claims come from a passport or ID card chip checked by Prson (level 5 or 6).

ClaimMeaningSharedExample
verified_humantrue when Prson checked a passport or ID card chip (level 5 or 6).Alwaystrue
verification_levelThe identity's level, 0 to 6.Always6
strength"strong" at level 5 and 6, "none" below.Always"strong"
clone_protectionHow the chip proved it is genuine: "active_auth" or "chip_auth".Always"chip_auth"
face_match"device_attested" at level 6: a live selfie matched the chip photo on the phone.Always"device_attested"
verified_human_atWhen the identity was verified.Always"2026-10-02T09:14:00Z"
selfie_hashesHashes of the selfies taken when verifying, if the person shares them.When asked["9f2c…"]
assuranceBelow level 5: "device_attested" (a genuine phone checked) or "self_asserted".Always"device_attested"
given_nameGiven names, from the chip.When asked"ANA MARIA"
family_nameFamily name, from the chip.When asked"SILVA"
birthdateDate of birth, from the chip.When asked"1990-05-17"
sexSex, as on the document.When asked"F"
document_numberThe document's number.When asked"CB1234567"
nationalitiesNationality, from the chip.When asked["PRT"]
document_typeThe kind of document: passport or national_id.When asked"passport"
issuing_countryThe issuing country, ISO 3166-1 alpha-3.When asked"PRT"
document_expiry_dateThe document's expiry date.When asked"2031-04-30"

One account per person

With required.uniqueHumanId: true only people with a chip-checked document can sign in, and you get an ID that is the same for this person on your site even if they reinstall the app. It can't be linked to them anywhere else.

unique.mjs
// Page: signIn({ nonce, required: { uniqueHumanId: true } }) and post result.uniqueHumanId too.
import { verifyUniqueHumanId } from "@prson/login-verifier";

const u = await verifyUniqueHumanId(req.body.uniqueHumanId, { expectedAudience: "shop.example.com" });
if (u.ok) {
  // The same for this person on your site every time, whatever wallet or key they use; unlinkable elsewhere.
  accountKey = req.body.uniqueHumanId.envelope.uniqueIdHash;
}

Notifications

With notifications: true the wallet offers an unticked "Let your site send you notifications". When the person ticks it, you get a handle for them on your site. Plain text only, about 3 a day per person. They can mute or turn you off at any time.

notify.mjs
// Page: signIn({ nonce, notifications: true }) → result.notifications?.handle (when the person said yes)
import { readNotifyHandle, sendNotification } from "@prson/login-verifier";

const handle = readNotifyHandle(req.body.notifications);   // keep it with the account
const sent = await sendNotification({
  apiKey: process.env.PRSON_API_KEY,                         // a key with notify:write
  handle,
  title: "Your order shipped",                              // up to 60 characters
  body: "It arrives Thursday.",                             // up to 180 characters
  url: "https://shop.example.com/orders/1042",              // optional, your sign-in domain only
});
if (!sent.ok && sent.revoked) forgetHandle(handle);         // 410: they turned you off

The live session

result.session stays connected to the wallet: it renews the credential, and tells you when something changes or the person signs out from their phone.

EventWhen
signed_inOnce, right after sign-in.
renewedThe wallet sent a fresh credential.
claim_added, claim_updated, claim_removedA shared claim changed, in a re-verified credential.
display_name_changed, avatar_changedDisplay hints only: don't use them for decisions.
credential_revokedThe credential was revoked; signed_out follows.
signed_outThe session ended, with a reason.
session.js
const { session } = await PrsonLogin.signIn({ nonce });

session.on("claim_updated", (e) => refreshProfile(e.name));
session.on("signed_out", (e) => logOut(e.reason));   // ttl_reached, wallet_signed_out, user_revoked, network_loss, credential_revoked
session.on("credential_revoked", () => logOut("revoked"));

// Sign out from your side:
session.close();