From e492bb40fdfe4abadef63640860ceaf369f3b4ad Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:43:22 +0000 Subject: [PATCH 1/8] docs: use the Motoko identity-attributes library for II attribute verification Reframe the Internet Identity attribute flow around the two-method protocol (_internet_identity_sign_in_start / _internet_identity_sign_in_finish): the mo:identity-attributes mixin provides it in Motoko, hand-written in Rust so a single frontend works against either backend. The frontend now runs nonce, sign-in, and the attribute request in parallel and requests name and verified_email. Adds frontend_origins to the icp.yaml env vars and updates the storing-the-nonce guidance and common-mistakes entry. --- .../authentication/internet-identity.mdx | 296 +++++++++++------- 1 file changed, 185 insertions(+), 111 deletions(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index 9354017..5e886be 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -151,7 +151,7 @@ async function createAuthenticatedActor(identity, canisterId, idlFactory) { ### Requesting identity attributes -When a backend canister needs more than just the user's principal (for example, a verified email address), Internet Identity can return signed attributes alongside the delegation. Your backend issues a nonce scoped to a specific action; the frontend requests the attributes during sign-in; the backend verifies the bundle when the user calls the protected method. +When a backend canister needs more than just the user's principal (for example, a verified email address), Internet Identity can return signed attributes alongside the delegation. The flow is a two-method handshake on the backend: `_internet_identity_sign_in_start` mints a nonce, and `_internet_identity_sign_in_finish` verifies the bundle. In Motoko the [`mo:identity-attributes`](https://mops.one/identity-attributes) library provides both methods; in Rust you implement them by hand (see [Read identity attributes](#read-identity-attributes)). The frontend below is identical against either backend. **Why a backend-issued nonce?** Tying attributes to a canister-issued nonce prevents replay: an intercepted bundle cannot be reused for a different action, on a different user, or after that action expires. The nonce must originate from the canister, not the frontend. @@ -161,39 +161,46 @@ import { AttributesIdentity } from "@icp-sdk/core/identity"; import { HttpAgent, Actor } from "@icp-sdk/core/agent"; import { Principal } from "@icp-sdk/core/principal"; -async function registerWithEmail() { - // 1. Backend issues a nonce scoped to this registration +const II_PRINCIPAL = "rdmx6-jaaaa-aaaaa-aaadq-cai"; + +// `idl` and `canisterId` identify your backend, which exposes +// _internet_identity_sign_in_start / _internet_identity_sign_in_finish. +async function signInWithAttributes(authClient, canisterId, idl) { + // Anonymous handle, used only to mint the nonce. const anonymousAgent = await HttpAgent.create(); - const backend = Actor.createActor(backendIdl, { - agent: anonymousAgent, - canisterId, - }); - const nonce = await backend.registerBegin(); + const anonymousActor = Actor.createActor(idl, { agent: anonymousAgent, canisterId }); - // 2. Run sign-in and the attribute request in parallel. - // The user sees a single Internet Identity interaction. + // Mint the nonce, sign in, and request attributes in parallel. Passing the + // nonce as a promise lets requestAttributes start before it resolves, so the + // user still sees a single Internet Identity interaction. + const noncePromise = anonymousActor._internet_identity_sign_in_start(); const signInPromise = authClient.signIn(); const attributesPromise = authClient.requestAttributes({ - keys: ["email"], - nonce, + keys: ["name", "verified_email"], // the library reads verified_email for its email field + nonce: noncePromise, }); const identity = await signInPromise; - const { data, signature } = await attributesPromise; - - // 3. Wrap the identity so the signed attributes travel with each call - const identityWithAttributes = new AttributesIdentity({ - inner: identity, - attributes: { data, signature }, - // The Internet Identity backend canister ID is the attribute signer - signer: { canisterId: Principal.fromText("rdmx6-jaaaa-aaaaa-aaadq-cai") }, + const attributes = await attributesPromise; + + // Wrap the identity so the signed bundle travels as sender_info on each call. + const verifiedAgent = await HttpAgent.create({ + identity: new AttributesIdentity({ + inner: identity, + attributes, + // The Internet Identity backend canister is the trusted attribute signer. + signer: { canisterId: Principal.fromText(II_PRINCIPAL) }, + }), }); + const verifiedActor = Actor.createActor(idl, { agent: verifiedAgent, canisterId }); - // 4. Call the protected method. The backend verifies the nonce, origin, - // and timestamp, then reads the email. - const agent = await HttpAgent.create({ identity: identityWithAttributes }); - const app = Actor.createActor(appIdl, { agent, canisterId }); - await app.registerFinish(); + // The backend verifies signer, origin, nonce, and freshness, then runs its + // verification logic. Returns { ok } on success, { err } otherwise. + const result = await verifiedActor._internet_identity_sign_in_finish(); + if ("err" in result) { + throw new Error(`Attribute verification failed: ${JSON.stringify(result.err)}`); + } + return identity; } ``` @@ -217,13 +224,12 @@ const authClient = new AuthClient({ openIdProvider: "google", }); -const nonce = await backend.registerBegin(); -const signInPromise = authClient.signIn(); -// Requests name, email, and verified_email from the Google account -// linked to the user's Internet Identity. +// In signInWithAttributes, request the Google-scoped keys instead. They arrive +// in the bundle as e.g. "openid:https://accounts.google.com:verified_email", +// and the mo:identity-attributes library maps them onto the same name/email fields. const attributesPromise = authClient.requestAttributes({ - keys: scopedKeys({ openIdProvider: "google" }), - nonce, + keys: scopedKeys({ openIdProvider: "google", keys: ["name", "verified_email"] }), + nonce: noncePromise, }); ``` @@ -315,93 +321,104 @@ async fn protected_async_action() -> String { ### Read identity attributes -When the frontend wraps an identity with `AttributesIdentity`, every call carries a verified attribute bundle. The IC checks that the bundle is signed; it does not check *who* signed it, and any canister could have signed an arbitrary one. Trust the bundle only when the signer is the Internet Identity backend (`rdmx6-jaaaa-aaaaa-aaadq-cai`). - -How that check is wired depends on the language: - -- **Motoko (mo:core >= 2.5.0)**: `CallerAttributes.getAttributes()` from `mo:core/CallerAttributes` returns the bundle as `?Blob` and traps when the signer is not listed in the canister's `trusted_attribute_signers` environment variable. Configure the env var in your `icp.yaml` (see below) and the trusted-signer check happens automatically. -- **Rust (ic-cdk >= 0.20.1)**: `ic_cdk::api::msg_caller_info_data() -> Vec` returns the raw bundle and `ic_cdk::api::msg_caller_info_signer() -> Option` returns the signer. There is no CDK wrapper for the trusted-signer check yet, so check the signer explicitly before reading the data. +The backend exposes two methods the frontend calls: `_internet_identity_sign_in_start` (mints a nonce) and `_internet_identity_sign_in_finish` (verifies the wrapped bundle and runs your logic). The checks are the same in both languages: the bundle must be signed by a trusted signer, its `implicit:origin` must be one you allow, its `implicit:issued_at_timestamp_ns` must be fresh, and its `implicit:nonce` must be one you issued and have not consumed. Motoko gets these checks from a library; Rust does them by hand. -For Motoko, declare the trusted signer in your `icp.yaml`. The value is a comma-separated list of principal texts, so list both your local and mainnet II principals if your tests run against a locally deployed II: - -```yaml -canisters: - - name: backend - settings: - environment_variables: - trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai" -``` - -If the env var is unset, `getAttributes` traps. That is the correct behavior: an unconfigured canister should not trust any attribute bundles. +**Always verify the signer.** The IC checks that the bundle is signed; it does not check *who* signed it, and any canister could have signed an arbitrary one. The trusted signer for Internet Identity is `rdmx6-jaaaa-aaaaa-aaadq-cai`. The bundle is Candid-encoded as an [ICRC-3 Value](../../references/internet-identity-spec.md) `Map` with three implicit fields plus the keys you requested: -- `implicit:nonce`: must equal a nonce your canister issued for this user and action. +- `implicit:nonce`: must equal a nonce your canister issued and not yet consumed. - `implicit:origin`: must equal a trusted frontend origin. - `implicit:issued_at_timestamp_ns`: reject if too old (a few minutes is typical). -- Plain attribute keys (e.g., `"email"`) for default-scope attributes; OpenID-scoped keys (e.g., `"openid:https://accounts.google.com:email"`) when the frontend used `scopedKeys`. +- Plain attribute keys (for example, `"verified_email"`) for default-scope attributes; OpenID-scoped keys (for example, `"openid:https://accounts.google.com:verified_email"`) when the frontend used `scopedKeys`. +The [`mo:identity-attributes`](https://mops.one/identity-attributes) mixin injects both methods and runs your `onVerified` callback only on a bundle that passes every check. Add it to `mops.toml`: + +```toml +[dependencies] +identity-attributes = "0.4.1" +core = "2.5.0" + +[toolchain] +moc = "1.6.0" +``` + +`onVerified` receives the resolved `{ name : ?Text; email : ?Text; sso : ?Text }`. The `email` field comes from the `verified_email` key (or its scoped form), which is why the frontend requests `verified_email`. The `sso` field is the matched trusted domain when name and email came from `sso:` keys, otherwise `null`. + ```motoko -import CallerAttributes "mo:core/CallerAttributes"; +import IdentityAttributes "mo:identity-attributes"; +import Map "mo:core/Map"; import Principal "mo:core/Principal"; -import Runtime "mo:core/Runtime"; persistent actor { - type Icrc3Value = { - #Nat : Nat; - #Int : Int; - #Blob : Blob; - #Text : Text; - #Array : [Icrc3Value]; - #Map : [(Text, Icrc3Value)]; - }; + type Profile = { name : ?Text; email : ?Text; sso : ?Text }; + + let profiles = Map.empty(); - func lookupText(entries : [(Text, Icrc3Value)], key : Text) : ?Text { - for ((k, v) in entries.vals()) { - if (k == key) { - switch v { case (#Text t) { return ?t }; case _ {} }; - }; + // Injects _internet_identity_sign_in_start / _internet_identity_sign_in_finish. + // onVerified runs only on a bundle that passed the signer, origin, nonce, and + // freshness checks. Map's compare is an implicit parameter (moc 1.6.0). + include IdentityAttributes({ + onVerified = func(caller, attrs) { + Map.add(profiles, caller, attrs); }; - null; - }; + }); - // Returns the verified attribute map. Traps when the signer is not - // listed in the canister's trusted_attribute_signers env var. - func iiAttributes() : [(Text, Icrc3Value)] { - let ?data = CallerAttributes.getAttributes() else Runtime.trap("no trusted attributes"); - let ?value : ?Icrc3Value = from_candid (data) else Runtime.trap("invalid attribute bundle"); - let #Map(entries) = value else Runtime.trap("expected attribute map"); - entries + public query func getProfile(caller : Principal) : async ?Profile { + Map.get(profiles, caller) }; +}; +``` - public shared ({ caller }) func registerFinish() : async Text { - if (Principal.isAnonymous(caller)) Runtime.trap("Anonymous caller not allowed"); - let entries = iiAttributes(); - - let ?origin = lookupText(entries, "implicit:origin") else Runtime.trap("missing origin"); - if (origin != "https://your-app.icp0.io") Runtime.trap("Wrong origin"); - - // Compare implicit:nonce to the nonce you minted in registerBegin (omitted for brevity) - // and check implicit:issued_at_timestamp_ns is within your freshness window. +Configure the env vars in your `icp.yaml` so `icp deploy` sets them on the canister. The values are comma-separated, so list both your local and mainnet II principals if your tests run against a locally deployed II: - let ?email = lookupText(entries, "email") else Runtime.trap("missing email"); - "Registered " # Principal.toText(caller) # " with email " # email - }; -}; +```yaml +canisters: + - name: backend + settings: + environment_variables: + trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai" # required + frontend_origins: "https://your-app.icp0.io" # required, comma-separated + trusted_sso_domains: "your-org.com" # optional; omit to reject all sso:* keys ``` +If `trusted_attribute_signers` is unset the bundle is rejected as untrusted; if `frontend_origins` is unset the finish method returns `#err(#FrontendOriginsNotConfigured)`. Both are correct: an unconfigured canister must not trust attribute bundles. + +There is no CDK wrapper yet, so implement the two methods by hand. `_internet_identity_sign_in_start` mints a nonce and stores it; `_internet_identity_sign_in_finish` checks the signer with `msg_caller_info_signer()`, decodes the ICRC-3 `Value::Map` from `msg_caller_info_data()`, then verifies origin, freshness, and the nonce before reading attributes. This mirrors what the Motoko library does internally. + ```rust use candid::{decode_one, CandidType, Deserialize, Principal}; -use ic_cdk::api::{msg_caller, msg_caller_info_data, msg_caller_info_signer}; +use ic_cdk::api::{msg_caller, msg_caller_info_data, msg_caller_info_signer, time}; use ic_cdk::update; +use std::cell::RefCell; +use std::collections::HashSet; const II_PRINCIPAL: &str = "rdmx6-jaaaa-aaaaa-aaadq-cai"; +const TRUSTED_ORIGIN: &str = "https://your-app.icp0.io"; +const FRESHNESS_NS: u64 = 300_000_000_000; // 5 minutes + +thread_local! { + // Nonces issued by sign_in_start, consumed by sign_in_finish. Keyed by the + // nonce itself, since start is called anonymously. See the storing-the-nonce + // note below. + static PENDING_NONCES: RefCell>> = RefCell::new(HashSet::new()); +} + +// Mirrors the mo:identity-attributes Result so the frontend "err" check works +// against either backend. +#[derive(CandidType)] +enum SignInResult { + #[serde(rename = "ok")] + Ok, + #[serde(rename = "err")] + Err(String), +} #[derive(CandidType, Deserialize)] enum Icrc3Value { @@ -420,36 +437,93 @@ fn lookup_text<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a }) } -// Returns the verified attribute entries, trapping if the signer is not II. -fn ii_attributes() -> Vec<(String, Icrc3Value)> { +fn lookup_blob<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a [u8]> { + entries.iter().find_map(|(k, v)| match v { + Icrc3Value::Blob(b) if k == key => Some(b.as_slice()), + _ => None, + }) +} + +fn lookup_nat<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a candid::Nat> { + entries.iter().find_map(|(k, v)| match v { + Icrc3Value::Nat(n) if k == key => Some(n), + _ => None, + }) +} + +// Mint a fresh nonce. The frontend calls this anonymously before sign-in. +#[update] +async fn _internet_identity_sign_in_start() -> Vec { + // 32 bytes of IC randomness. raw_rand lives at + // ic_cdk::management_canister::raw_rand in ic-cdk >= 0.18. + let nonce = ic_cdk::management_canister::raw_rand() + .await + .expect("raw_rand failed"); + PENDING_NONCES.with_borrow_mut(|n| n.insert(nonce.clone())); + nonce +} + +// Runs every check the mo:identity-attributes mixin runs internally. +fn verified_attributes() -> Result, String> { + // 1. Trusted signer: the IC checks the signature, not who signed it. let trusted = Principal::from_text(II_PRINCIPAL).unwrap(); if msg_caller_info_signer() != Some(trusted) { - ic_cdk::trap("Untrusted attribute signer"); + return Err("Untrusted attribute signer".to_string()); + } + + // 2. Decode the bundle as an ICRC-3 Value::Map. + let value: Icrc3Value = + decode_one(&msg_caller_info_data()).map_err(|_| "Malformed attribute bundle".to_string())?; + let Icrc3Value::Map(entries) = value else { + return Err("Expected attribute map".to_string()); + }; + + // 3. Origin must be one we allow. + let origin = lookup_text(&entries, "implicit:origin").ok_or("Missing origin")?; + if origin != TRUSTED_ORIGIN { + return Err(format!("Untrusted frontend origin: {origin}")); + } + + // 4. Bundle must be fresh. + let issued_at: u64 = lookup_nat(&entries, "implicit:issued_at_timestamp_ns") + .ok_or("Missing timestamp")? + .0 + .clone() + .try_into() + .map_err(|_| "Timestamp out of range".to_string())?; + if time() > issued_at + FRESHNESS_NS { + return Err("Bundle too old".to_string()); } - let bundle = msg_caller_info_data(); - let value: Icrc3Value = decode_one(&bundle).unwrap_or_else(|_| ic_cdk::trap("invalid attribute bundle")); - match value { - Icrc3Value::Map(entries) => entries, - _ => ic_cdk::trap("expected attribute map"), + + // 5. Nonce must be one we issued and have not consumed yet. + let nonce = lookup_blob(&entries, "implicit:nonce").ok_or("Missing nonce")?; + if !PENDING_NONCES.with_borrow_mut(|n| n.remove(nonce)) { + return Err("Unknown or already-consumed nonce".to_string()); } + + Ok(entries) } #[update] -fn register_finish() -> String { +fn _internet_identity_sign_in_finish() -> SignInResult { let caller = msg_caller(); - if caller == Principal::anonymous() { ic_cdk::trap("Anonymous caller not allowed"); } - let entries = ii_attributes(); - - let origin = lookup_text(&entries, "implicit:origin") - .unwrap_or_else(|| ic_cdk::trap("missing origin")); - if origin != "https://your-app.icp0.io" { ic_cdk::trap("Wrong origin"); } + if caller == Principal::anonymous() { + return SignInResult::Err("Anonymous caller not allowed".to_string()); + } + let entries = match verified_attributes() { + Ok(entries) => entries, + Err(e) => return SignInResult::Err(e), + }; - // Compare implicit:nonce to the nonce you minted in register_begin (omitted for brevity) - // and check implicit:issued_at_timestamp_ns is within your freshness window. + // Your app logic. verified_email gates access. + let Some(email) = lookup_text(&entries, "verified_email") else { + return SignInResult::Err("Missing verified_email".to_string()); + }; + let name = lookup_text(&entries, "name"); + // For example, persist a profile keyed by `caller` here. + let _ = (caller, email, name); - let email = lookup_text(&entries, "email") - .unwrap_or_else(|| ic_cdk::trap("missing email")); - format!("Registered {} with email {}", caller, email) + SignInResult::Ok } ``` @@ -457,7 +531,7 @@ fn register_finish() -> String { :::tip[Storing the nonce] -Mint the nonce in your `registerBegin` (or equivalent) method and persist it in stable memory keyed by the user's principal and the action name. Mark it consumed in `registerFinish` so a bundle cannot be replayed. Use a short freshness window so abandoned attempts age out. +`_internet_identity_sign_in_start` mints the nonce; store it server-side keyed by the nonce itself (start is called anonymously, so there is no caller to key by) and consume it in `_internet_identity_sign_in_finish` so a bundle cannot be replayed. The Motoko library keeps this store internally; in Rust the `thread_local` above does the job. Use a short freshness window so abandoned attempts age out. To survive upgrades, persist the store in stable memory (see the [stable structures](https://docs.rs/ic-stable-structures/latest/ic_stable_structures/) crate). ::: ## Local development @@ -550,7 +624,7 @@ For full details, see the [Internet Identity specification](../../references/int - **Using `shouldFetchRootKey: true` in browser code**: pass `rootKey: canisterEnv?.IC_ROOT_KEY` from `safeGetCanisterEnv()` instead. `shouldFetchRootKey: true` fetches the root key from the replica at runtime, which lets a man-in-the-middle substitute a fake key on mainnet. For Node.js scripts targeting a local replica only, `await agent.fetchRootKey()` is acceptable: but never on mainnet. - **Creating multiple `AuthClient` instances**: create one on page load and reuse it. Multiple instances cause race conditions with session storage. - **Generating the attribute nonce on the frontend**: a frontend-generated nonce defeats the anti-replay guarantee. The nonce passed to `requestAttributes` must come from a backend canister call so the canister can later verify that the bundle's `implicit:nonce` matches an action it actually started. -- **Reading attribute data without verifying the signer**: the IC checks the signature, not the identity of the signer, so any canister can produce a valid bundle. The trusted signer for II is `rdmx6-jaaaa-aaaaa-aaadq-cai`. In Motoko, use `CallerAttributes.getAttributes()` from `mo:core/CallerAttributes` and configure the `trusted_attribute_signers` env var in `icp.yaml`: the wrapper traps when an untrusted signer is detected. In Rust, there is no CDK wrapper yet, so always check `msg_caller_info_signer()` against the trusted issuer before reading `msg_caller_info_data()`. +- **Reading attribute data without verifying the signer**: the IC checks the signature, not the identity of the signer, so any canister can produce a valid bundle. The trusted signer for II is `rdmx6-jaaaa-aaaaa-aaadq-cai`. In Motoko, use the [`mo:identity-attributes`](https://mops.one/identity-attributes) mixin and configure `trusted_attribute_signers` and `frontend_origins` in `icp.yaml`: it verifies the signer (and the origin, nonce, and freshness) for you. In Rust, there is no CDK wrapper yet, so always check `msg_caller_info_signer()` against the trusted issuer before reading `msg_caller_info_data()`. ## Next steps @@ -562,4 +636,4 @@ For full details, see the [Internet Identity specification](../../references/int {/* TODO: Add Unity native app integration via deep links: see portal native-apps/unity_ii_* */} -{/* Upstream: informed by dfinity/portal (docs/building-apps/authentication/overview.mdx, docs/building-apps/authentication/integrate-internet-identity.mdx, docs/building-apps/authentication/alternative-origins.mdx); dfinity/icskills (skills/internet-identity/SKILL.md); dfinity/icp-js-sdk-docs (public/auth/latest.zip api/client/ — AuthClient, scopedKeys, SignedAttributes, AuthClientCreateOptions; public/core/latest.zip libs/identity/api.md — AttributesIdentity); dfinity/cdk-rs (ic-cdk/src/api.rs); caffeinelabs/motoko-core (src/CallerAttributes.mo getAttributes wrapper); caffeinelabs/motoko (src/prelude/prim.mo callerInfoData/Signer, test/run-drun/caller-info/caller-info.mo); dfinity/icp-cli (docs/reference/canister-settings.md#environment_variables) */} +{/* Upstream: informed by dfinity/portal (docs/building-apps/authentication/overview.mdx, docs/building-apps/authentication/integrate-internet-identity.mdx, docs/building-apps/authentication/alternative-origins.mdx); dfinity/icskills (skills/internet-identity/SKILL.md); dfinity/icp-js-sdk-docs (public/auth/latest.zip api/client/ — AuthClient, scopedKeys, SignedAttributes, AuthClientCreateOptions; public/core/latest.zip libs/identity/api.md — AttributesIdentity); dfinity/cdk-rs (ic-cdk/src/api.rs); dfinity/motoko-identity-attributes (README.md, src/lib.mo, src/Internal/Verify.mo @ v0.4.1 — the mixin and its verification order); caffeinelabs/motoko-core (src/CallerAttributes.mo getAttributes wrapper, src/Map.mo); caffeinelabs/motoko (src/prelude/prim.mo callerInfoData/Signer, test/run-drun/caller-info/caller-info.mo); dfinity/icp-cli (docs/reference/canister-settings.md#environment_variables) */} From 7046797a997d6c6de1b0bb90a1c126151087e851 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:09:57 +0200 Subject: [PATCH 2/8] docs: drop redundant anonymous-caller check from the Rust attribute example verified_attributes already rejects callers without a trusted II bundle, so the inline anonymous check duplicated the Reject-anonymous-callers section. Mirrors the mo:identity-attributes mixin, which has no separate check. --- docs/guides/authentication/internet-identity.mdx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index 5e886be..75145b5 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -506,10 +506,8 @@ fn verified_attributes() -> Result, String> { #[update] fn _internet_identity_sign_in_finish() -> SignInResult { - let caller = msg_caller(); - if caller == Principal::anonymous() { - return SignInResult::Err("Anonymous caller not allowed".to_string()); - } + // No separate anonymous check: verified_attributes rejects any call without a + // trusted II bundle, which already excludes anonymous and unwrapped callers. let entries = match verified_attributes() { Ok(entries) => entries, Err(e) => return SignInResult::Err(e), @@ -519,6 +517,7 @@ fn _internet_identity_sign_in_finish() -> SignInResult { let Some(email) = lookup_text(&entries, "verified_email") else { return SignInResult::Err("Missing verified_email".to_string()); }; + let caller = msg_caller(); let name = lookup_text(&entries, "name"); // For example, persist a profile keyed by `caller` here. let _ = (caller, email, name); From 405af7d39b2d7329117d039058d506ea32cdf41c Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:52:00 +0200 Subject: [PATCH 3/8] docs: describe the nonce as single-use, not action-scoped With the fixed _internet_identity_sign_in_start/finish methods the nonce binds one sign-in handshake; it is not tied to a named action or user. Reword the replay-protection notes accordingly. --- docs/guides/authentication/internet-identity.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index 75145b5..dca9e59 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -153,7 +153,7 @@ async function createAuthenticatedActor(identity, canisterId, idlFactory) { When a backend canister needs more than just the user's principal (for example, a verified email address), Internet Identity can return signed attributes alongside the delegation. The flow is a two-method handshake on the backend: `_internet_identity_sign_in_start` mints a nonce, and `_internet_identity_sign_in_finish` verifies the bundle. In Motoko the [`mo:identity-attributes`](https://mops.one/identity-attributes) library provides both methods; in Rust you implement them by hand (see [Read identity attributes](#read-identity-attributes)). The frontend below is identical against either backend. -**Why a backend-issued nonce?** Tying attributes to a canister-issued nonce prevents replay: an intercepted bundle cannot be reused for a different action, on a different user, or after that action expires. The nonce must originate from the canister, not the frontend. +**Why a backend-issued nonce?** The canister issues a single-use nonce and consumes it on sign-in, so an intercepted bundle cannot be redeemed again. The nonce must originate from the canister, not the frontend. ```typescript import { AuthClient } from "@icp-sdk/auth/client"; @@ -206,11 +206,11 @@ async function signInWithAttributes(authClient, canisterId, idl) { Each signed attribute bundle carries three implicit fields the backend should verify: -- `implicit:nonce`: matches the canister-issued nonce, preventing replay across actions and users. +- `implicit:nonce`: matches a single-use nonce the canister issued and consumes on sign-in, so a captured bundle cannot be replayed. - `implicit:origin`: the requesting frontend origin, so a malicious dapp cannot forward attributes to a different backend. - `implicit:issued_at_timestamp_ns`: issuance time, letting the canister reject stale bundles even when the nonce is still valid. -Attributes can also be requested after sign-in, for example to link an email to an existing account. The pattern is the same: the backend issues a nonce for that action, the frontend calls `requestAttributes`, and the backend verifies the result. +Attributes can also be requested again later, for example to link an email to an existing account, by exposing another start/finish method pair: mint a fresh nonce, call `requestAttributes`, and verify the bundle the same way. #### OpenID-scoped attributes @@ -622,7 +622,7 @@ For full details, see the [Internet Identity specification](../../references/int - **Passing principal as a string argument**: the backend reads the caller automatically from the IC protocol. Do not pass it as a function parameter. - **Using `shouldFetchRootKey: true` in browser code**: pass `rootKey: canisterEnv?.IC_ROOT_KEY` from `safeGetCanisterEnv()` instead. `shouldFetchRootKey: true` fetches the root key from the replica at runtime, which lets a man-in-the-middle substitute a fake key on mainnet. For Node.js scripts targeting a local replica only, `await agent.fetchRootKey()` is acceptable: but never on mainnet. - **Creating multiple `AuthClient` instances**: create one on page load and reuse it. Multiple instances cause race conditions with session storage. -- **Generating the attribute nonce on the frontend**: a frontend-generated nonce defeats the anti-replay guarantee. The nonce passed to `requestAttributes` must come from a backend canister call so the canister can later verify that the bundle's `implicit:nonce` matches an action it actually started. +- **Generating the attribute nonce on the frontend**: a frontend-generated nonce defeats the anti-replay guarantee. The nonce passed to `requestAttributes` must come from a backend canister call so the canister can later verify that the bundle's `implicit:nonce` is one it actually issued. - **Reading attribute data without verifying the signer**: the IC checks the signature, not the identity of the signer, so any canister can produce a valid bundle. The trusted signer for II is `rdmx6-jaaaa-aaaaa-aaadq-cai`. In Motoko, use the [`mo:identity-attributes`](https://mops.one/identity-attributes) mixin and configure `trusted_attribute_signers` and `frontend_origins` in `icp.yaml`: it verifies the signer (and the origin, nonce, and freshness) for you. In Rust, there is no CDK wrapper yet, so always check `msg_caller_info_signer()` against the trusted issuer before reading `msg_caller_info_data()`. ## Next steps From 3c3a2ec19905575821431771f54f1d11526499a7 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:52:54 +0200 Subject: [PATCH 4/8] docs: drop compiler-internals comment from the Motoko example --- docs/guides/authentication/internet-identity.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index dca9e59..513f2c9 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -360,7 +360,7 @@ persistent actor { // Injects _internet_identity_sign_in_start / _internet_identity_sign_in_finish. // onVerified runs only on a bundle that passed the signer, origin, nonce, and - // freshness checks. Map's compare is an implicit parameter (moc 1.6.0). + // freshness checks. include IdentityAttributes({ onVerified = func(caller, attrs) { Map.add(profiles, caller, attrs); From 82d46ea9bf9ce7d1ec15b881c2a5a4b998ac6e83 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:57:04 +0200 Subject: [PATCH 5/8] docs: trim incidental implementation asides from the attribute examples Drop the nonce-keying rationale, the raw_rand location comment (the call is right there), and verbose upgrade/heap wording. Keep only purposeful comments. --- docs/guides/authentication/internet-identity.mdx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index 513f2c9..7decea7 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -404,9 +404,7 @@ const TRUSTED_ORIGIN: &str = "https://your-app.icp0.io"; const FRESHNESS_NS: u64 = 300_000_000_000; // 5 minutes thread_local! { - // Nonces issued by sign_in_start, consumed by sign_in_finish. Keyed by the - // nonce itself, since start is called anonymously. See the storing-the-nonce - // note below. + // Nonces issued by sign_in_start and consumed by sign_in_finish. static PENDING_NONCES: RefCell>> = RefCell::new(HashSet::new()); } @@ -454,8 +452,6 @@ fn lookup_nat<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a // Mint a fresh nonce. The frontend calls this anonymously before sign-in. #[update] async fn _internet_identity_sign_in_start() -> Vec { - // 32 bytes of IC randomness. raw_rand lives at - // ic_cdk::management_canister::raw_rand in ic-cdk >= 0.18. let nonce = ic_cdk::management_canister::raw_rand() .await .expect("raw_rand failed"); @@ -506,8 +502,7 @@ fn verified_attributes() -> Result, String> { #[update] fn _internet_identity_sign_in_finish() -> SignInResult { - // No separate anonymous check: verified_attributes rejects any call without a - // trusted II bundle, which already excludes anonymous and unwrapped callers. + // verified_attributes already rejects anonymous and untrusted callers. let entries = match verified_attributes() { Ok(entries) => entries, Err(e) => return SignInResult::Err(e), @@ -530,7 +525,7 @@ fn _internet_identity_sign_in_finish() -> SignInResult { :::tip[Storing the nonce] -`_internet_identity_sign_in_start` mints the nonce; store it server-side keyed by the nonce itself (start is called anonymously, so there is no caller to key by) and consume it in `_internet_identity_sign_in_finish` so a bundle cannot be replayed. The Motoko library keeps this store internally; in Rust the `thread_local` above does the job. Use a short freshness window so abandoned attempts age out. To survive upgrades, persist the store in stable memory (see the [stable structures](https://docs.rs/ic-stable-structures/latest/ic_stable_structures/) crate). +`_internet_identity_sign_in_start` mints the nonce; store it server-side and consume it in `_internet_identity_sign_in_finish` so a bundle cannot be replayed. Use a short freshness window so abandoned attempts age out. The Motoko library keeps this store internally; the Rust `thread_local` above resets on upgrade, so persist it in stable memory to survive upgrades (see the [stable structures](https://docs.rs/ic-stable-structures/latest/ic_stable_structures/) crate). ::: ## Local development From b79bc52b9054261d090d4d6c8c2e02ce70afdaa0 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:57:36 +0200 Subject: [PATCH 6/8] docs: remove the no-anonymous-check comment from the Rust example --- docs/guides/authentication/internet-identity.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index 7decea7..cf4a003 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -502,7 +502,6 @@ fn verified_attributes() -> Result, String> { #[update] fn _internet_identity_sign_in_finish() -> SignInResult { - // verified_attributes already rejects anonymous and untrusted callers. let entries = match verified_attributes() { Ok(entries) => entries, Err(e) => return SignInResult::Err(e), From 03c67fbb8c0abfe518e64058287b33626331b744 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:00:23 +0200 Subject: [PATCH 7/8] docs: move nonce-storage note into the Rust tab It was a shared tip outside the Tabs block, so it rendered under Motoko too, even though nonce storage is a Rust-only concern (the mo:identity-attributes library handles it). Now lives inside the Rust tab. --- docs/guides/authentication/internet-identity.mdx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index cf4a003..d515ed7 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -520,13 +520,11 @@ fn _internet_identity_sign_in_finish() -> SignInResult { } ``` +The `thread_local` above keeps consumed nonces in heap memory, which resets on upgrade; use stable memory if you want in-flight sign-ins to survive upgrades (see the [stable structures](https://docs.rs/ic-stable-structures/latest/ic_stable_structures/) crate). Keep the freshness window short so abandoned attempts age out. + -:::tip[Storing the nonce] -`_internet_identity_sign_in_start` mints the nonce; store it server-side and consume it in `_internet_identity_sign_in_finish` so a bundle cannot be replayed. Use a short freshness window so abandoned attempts age out. The Motoko library keeps this store internally; the Rust `thread_local` above resets on upgrade, so persist it in stable memory to survive upgrades (see the [stable structures](https://docs.rs/ic-stable-structures/latest/ic_stable_structures/) crate). -::: - ## Local development Start the local network and deploy. With `ii: true` in your `icp.yaml`, icp-cli deploys a local Internet Identity canister automatically: From d9165b32941afa22db90267261a800ae681b8719 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:24:14 +0200 Subject: [PATCH 8/8] docs: match the library README (profiles.add/get) and drop the nonce-storage note Use the method-call form from the mo:identity-attributes README instead of Map.add/Map.get, and remove the storing-the-nonce note. --- docs/guides/authentication/internet-identity.mdx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index d515ed7..0488993 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -363,12 +363,12 @@ persistent actor { // freshness checks. include IdentityAttributes({ onVerified = func(caller, attrs) { - Map.add(profiles, caller, attrs); + profiles.add(caller, attrs); }; }); public query func getProfile(caller : Principal) : async ?Profile { - Map.get(profiles, caller) + profiles.get(caller) }; }; ``` @@ -520,8 +520,6 @@ fn _internet_identity_sign_in_finish() -> SignInResult { } ``` -The `thread_local` above keeps consumed nonces in heap memory, which resets on upgrade; use stable memory if you want in-flight sign-ins to survive upgrades (see the [stable structures](https://docs.rs/ic-stable-structures/latest/ic_stable_structures/) crate). Keep the freshness window short so abandoned attempts age out. -