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
- Your page calls
PrsonLogin.signIn(). The SDK shows a dialog with a code (on a phone, an "Open Prson" button). - The person scans it, sees your domain and exactly what you ask for, and approves with Face ID or a fingerprint.
- The wallet sends a signed credential to your page over an end-to-end encrypted channel.
- 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-verifierSource and changelog: SDKs and packages.
Your page
<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
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
| Option | Meaning |
|---|---|
nonce | A one-time value from your server (or a function returning one). Required for a backend that trusts the result. |
required.minVerificationLevel | 0 to 6, default 1. 5: a passport or ID card chip checked by Prson. 0 accepts unverified identities. |
required.predicates | Fact ids the person must meet, such as age_gte_18. Yes or no only. |
required.requiredClaims | Claim names the person must share, such as given_name. |
required.identityType | personal or organization. |
required.uniqueHumanId | true: one verified person per account on your site (see below). |
allowAnonymous | true: also accept a sign-in with no identity, only a key for your site. |
ephemeralIdentity | true: a new key every sign-in, so visits can't be linked. |
notifications | true: offer the person to receive notifications from your site. |
ttlMinutes | How long the credential lives before the session renews it. |
ui | { mountQrInto, theme, getAppUrl }: draw the code in your page instead of the dialog. |
returnUrl | Same-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.
age_gte_{n}age_lte_{n}age_between_{a}_{b}verification_level_gte_{n}nationality_eq_{ISO3}, nationality_in_{eu|eea|schengen}issuing_country_eq_{ISO3}, issuing_country_in_{eu|eea|schengen}doc_expires_after_{n}mdocument_type_eq_{type}doc_not_expiredphone_verifiedemail_verifiedaddress_country_eq_{ISO2}, address_country_in_{eu|eea|schengen}address_verified
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).
| Claim | Meaning | Shared | Example |
|---|---|---|---|
verified_human | true when Prson checked a passport or ID card chip (level 5 or 6). | Always | true |
verification_level | The identity's level, 0 to 6. | Always | 6 |
strength | "strong" at level 5 and 6, "none" below. | Always | "strong" |
clone_protection | How 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_at | When the identity was verified. | Always | "2026-10-02T09:14:00Z" |
selfie_hashes | Hashes of the selfies taken when verifying, if the person shares them. | When asked | ["9f2c…"] |
assurance | Below level 5: "device_attested" (a genuine phone checked) or "self_asserted". | Always | "device_attested" |
given_name | Given names, from the chip. | When asked | "ANA MARIA" |
family_name | Family name, from the chip. | When asked | "SILVA" |
birthdate | Date of birth, from the chip. | When asked | "1990-05-17" |
sex | Sex, as on the document. | When asked | "F" |
document_number | The document's number. | When asked | "CB1234567" |
nationalities | Nationality, from the chip. | When asked | ["PRT"] |
document_type | The kind of document: passport or national_id. | When asked | "passport" |
issuing_country | The issuing country, ISO 3166-1 alpha-3. | When asked | "PRT" |
document_expiry_date | The 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.
// 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.
// 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 offThe 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.
| Event | When |
|---|---|
signed_in | Once, right after sign-in. |
renewed | The wallet sent a fresh credential. |
claim_added, claim_updated, claim_removed | A shared claim changed, in a re-verified credential. |
display_name_changed, avatar_changed | Display hints only: don't use them for decisions. |
credential_revoked | The credential was revoked; signed_out follows. |
signed_out | The session ended, with a reason. |
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();