Internet Identity
Internet Identity (II) is the Internet Computer’s native authentication system. Users sign in with passkeys or OpenID accounts (Google, Apple, Microsoft) instead of passwords. Each user receives a unique principal per frontend origin, preventing cross-app tracking.
This guide covers setting up II authentication end-to-end: configuring your project, adding sign-in to your frontend, and verifying callers in your backend.
How it works
When a user authenticates through Internet Identity, the following happens:
- Your frontend opens an II popup window.
- The user authenticates with a passkey or OpenID provider.
- II creates a delegation identity: a temporary key pair that can sign messages on behalf of the user’s master key.
- Your frontend receives this delegation and uses it to sign canister calls.
- The backend canister sees the user’s principal (derived from the delegation chain) as
msg.caller.
Principal-per-app isolation: II derives a different principal for each frontend origin. A user logging into https://app-a.icp0.io gets a different principal than when logging into https://app-b.icp0.io, even with the same passkey. This prevents apps from correlating users across services.
Delegations expire. The frontend sets a maxTimeToLive when requesting the delegation (default recommendation: 8 hours). After expiry, the user must re-authenticate. The maximum allowed delegation lifetime is 30 days (2,592,000,000,000,000 nanoseconds).
Project setup
Configure icp.yaml for local Internet Identity
Add ii: true to your local network configuration. This tells icp-cli to deploy a local Internet Identity canister automatically:
networks: - name: local mode: managed ii: trueInstall frontend packages
npm install @icp-sdk/auth @icp-sdk/coreFrontend integration
The AuthClient from @icp-sdk/auth handles the full sign-in flow: opening the II popup, receiving the delegation, and managing session persistence.
Environment detection
Internet Identity runs at different URLs in local development versus mainnet. II uses a well-known frontend canister (uqzsh-gqaaa-aaaaq-qaada-cai) that you authenticate against. Detect the host to return the right URL:
import { AuthClient } from "@icp-sdk/auth/client";import { HttpAgent, Actor } from "@icp-sdk/core/agent";import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env";
// Read the ic_env cookie set by the asset canister or Vite dev server.// Contains IC_ROOT_KEY and canister IDs: works in both local and production without// environment branching. Available in browser contexts only; see note below for Node.js.const canisterEnv = safeGetCanisterEnv();
function getIdentityProviderUrl() { const host = window.location.hostname; const isLocal = host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost");
if (isLocal) { // icp-cli sets up a local alias: http://id.ai.localhost:8000 return "http://id.ai.localhost:8000/authorize"; } return "https://id.ai/authorize";}Sign in, sign out, and session check
Create a single AuthClient instance on page load and reuse it for all operations. The identity provider URL is passed at construction time, not on each sign-in:
// Create the auth client (once, on page load)const authClient = new AuthClient({ identityProvider: getIdentityProviderUrl(),});
// Check for existing sessionif (authClient.isAuthenticated()) { const identity = await authClient.getIdentity(); // Restore session: create agent and actor with this identity}
// Sign inasync function signIn() { try { const identity = await authClient.signIn({ maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours }); console.log("Signed in as:", identity.getPrincipal().toText()); return identity; } catch (error) { console.error("Sign-in failed:", error); throw error; }}
// Sign outasync function signOut() { await authClient.signOut(); // Reset UI state or reload}signIn() returns the new Identity directly. It rejects if the user closes the popup or authentication fails, so wrap the call in try/catch instead of relying on success/error callbacks.
One-click OpenID sign-in
To skip the Internet Identity authentication-method screen and send the user straight to a specific OpenID provider, pass openIdProvider to the constructor. Supported values are 'google', 'apple', and 'microsoft':
const authClient = new AuthClient({ identityProvider: getIdentityProviderUrl(), openIdProvider: "google",});The rest of the flow (signIn, getIdentity, signOut) is unchanged.
Create an authenticated agent
After sign-in, create an HttpAgent using the delegation identity. The agent signs all subsequent canister calls with the user’s delegated key:
async function createAuthenticatedActor(identity, canisterId, idlFactory) { const agent = await HttpAgent.create({ identity, host: window.location.origin, rootKey: canisterEnv?.IC_ROOT_KEY, });
return Actor.createActor(idlFactory, { agent, canisterId });}Node.js environments
safeGetCanisterEnv() reads the ic_env cookie set by the asset canister or Vite dev server (it only works in browser contexts. For Node.js scripts or tests connecting to a local replica, create the agent normally and call await agent.fetchRootKey() explicitly after creation. Never call fetchRootKey() against a mainnet endpoint) on mainnet the root key is pre-trusted, and fetching it at runtime exposes a man-in-the-middle risk.
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. 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 library provides both methods; in Rust you implement them by hand (see Read identity attributes). The frontend below is identical against either backend.
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.
import { AuthClient } from "@icp-sdk/auth/client";import { AttributesIdentity } from "@icp-sdk/core/identity";import { HttpAgent, Actor } from "@icp-sdk/core/agent";import { Principal } from "@icp-sdk/core/principal";
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 anonymousActor = Actor.createActor(idl, { agent: anonymousAgent, canisterId });
// 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: ["name", "verified_email"], // the library reads verified_email for its email field nonce: noncePromise, });
const identity = await signInPromise; 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 });
// 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;}Each signed attribute bundle carries three implicit fields the backend should verify:
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 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
When using one-click OpenID sign-in, attributes can be scoped to the provider. The user authenticates and shares attributes in a single step, with no extra prompt:
import { AuthClient, scopedKeys } from "@icp-sdk/auth/client";
const authClient = new AuthClient({ identityProvider: getIdentityProviderUrl(), openIdProvider: "google",});
// 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", keys: ["name", "verified_email"] }), nonce: noncePromise,});Backend authentication
Your backend canister receives the caller’s principal automatically through the IC protocol. You do not pass the principal as a function argument: use msg.caller (Motoko) or ic_cdk::api::msg_caller() (Rust) to read it.
Reject anonymous callers
Any unauthenticated request uses the anonymous principal (2vxsx-fae). Reject it in protected endpoints:
import Principal "mo:core/Principal";import Runtime "mo:core/Runtime";
persistent actor { func requireAuth(caller : Principal) : () { if (Principal.isAnonymous(caller)) { Runtime.trap("Anonymous principal not allowed."); }; };
public shared query ({ caller }) func whoAmI() : async Text { if (Principal.isAnonymous(caller)) { "anonymous" } else { Principal.toText(caller) }; };
public shared ({ caller }) func protectedAction() : async Text { requireAuth(caller); "Action performed by " # Principal.toText(caller) };};use candid::Principal;use ic_cdk::{query, update};
fn require_auth() -> Principal { let caller = ic_cdk::api::msg_caller(); if caller == Principal::anonymous() { ic_cdk::trap("Anonymous principal not allowed."); } caller}
#[query]fn who_am_i() -> String { let caller = ic_cdk::api::msg_caller(); if caller == Principal::anonymous() { "anonymous".to_string() } else { format!("{}", caller) }}
#[update]fn protected_action() -> String { let caller = require_auth(); format!("Action performed by {}", caller)}Rust: capture caller before await
In async update functions, bind the caller at the top of the function before any .await points. The current ic-cdk executor preserves the caller across await points, but capturing it early is a defensive practice that guards against future executor changes:
#[update]async fn protected_async_action() -> String { let caller = require_auth(); // Capture before any await // Replace with your actual async canister call, e.g.: // ic_cdk::call::<_, (String,)>(some_canister_id, "some_method", ()).await format!("Action completed by {}", caller)}Read identity attributes
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.
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 Map with three implicit fields plus the keys you requested:
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 (for example,
"verified_email") for default-scope attributes; OpenID-scoped keys (for example,"openid:https://accounts.google.com:verified_email") when the frontend usedscopedKeys.
The mo:identity-attributes mixin injects both methods and runs your onVerified callback only on a bundle that passes every check. Add it to mops.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.
import IdentityAttributes "mo:identity-attributes";import Map "mo:core/Map";import Principal "mo:core/Principal";
persistent actor { type Profile = { name : ?Text; email : ?Text; sso : ?Text };
let profiles = Map.empty<Principal, Profile>();
// 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. include IdentityAttributes({ onVerified = func(caller, attrs) { profiles.add(caller, attrs); }; });
public query func getProfile(caller : Principal) : async ?Profile { profiles.get(caller) };};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:
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:* keysIf 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.
use candid::{decode_one, CandidType, Deserialize, Principal};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 and consumed by sign_in_finish. static PENDING_NONCES: RefCell<HashSet<Vec<u8>>> = 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 { Nat(candid::Nat), Int(candid::Int), Blob(Vec<u8>), Text(String), Array(Vec<Icrc3Value>), Map(Vec<(String, Icrc3Value)>),}
fn lookup_text<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a str> { entries.iter().find_map(|(k, v)| match v { Icrc3Value::Text(s) if k == key => Some(s.as_str()), _ => None, })}
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<u8> { 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<Vec<(String, Icrc3Value)>, 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) { 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()); }
// 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 _internet_identity_sign_in_finish() -> SignInResult { let entries = match verified_attributes() { Ok(entries) => entries, Err(e) => return SignInResult::Err(e), };
// 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 caller = msg_caller(); let name = lookup_text(&entries, "name"); // For example, persist a profile keyed by `caller` here. let _ = (caller, email, name);
SignInResult::Ok}Local development
Start the local network and deploy. With ii: true in your icp.yaml, icp-cli deploys a local Internet Identity canister automatically:
icp network starticp deployicp-cli pulls the mainnet II Wasm when deploying locally and registers a local alias so the II frontend is reachable at http://id.ai.localhost:8000. Use the getIdentityProviderUrl helper (shown in the environment detection section above) to point to this URL in local development.
To test authentication from the command line:
# Test as the default identity (authenticated)icp canister call backend whoAmI
# Test as anonymous using --identity to avoid changing your global defaulticp canister call backend protectedAction --identity anonymous# Expected: Error containing "Anonymous principal not allowed"For mainnet deployment, Internet Identity is already running: backend canister rdmx6-jaaaa-aaaaa-aaadq-cai and frontend canister uqzsh-gqaaa-aaaaq-qaada-cai (served at https://id.ai). Both IDs are identical on local replicas when ii: true is configured. Deploy only your own canisters:
icp deploy -e icAlternative origins
By default, each frontend origin produces a different user principal. If you serve your app from multiple domains (for example, migrating from <canister-id>.icp0.io to a custom domain), users would get different principals on each domain.
II now automatically handles the icp0.io vs ic0.app domain difference: you do not need to use derivationOrigin or ii-alternative-origins for that case. Use alternative origins only when you have two genuinely distinct custom domains that should share the same user principal.
To keep principals consistent across your own custom domains, configure alternative origins:
-
On the primary origin (A): Create a file at
.well-known/ii-alternative-originslisting the alternative domains:{"alternativeOrigins": ["https://www.yourcustomdomain.com"]}A maximum of 10 alternative origins can be listed. No trailing slashes or paths.
-
Configure the asset canister to serve the
.well-knowndirectory. Add an.ic-assets.json5in your frontend source:[{"match": ".well-known","ignore": false},{"match": ".well-known/ii-alternative-origins","headers": {"Access-Control-Allow-Origin": "*","Content-Type": "application/json"},"ignore": false}] -
On the alternative origin (B): Set the
derivationOriginon theAuthClientconstructor to point back to the primary origin:const authClient = new AuthClient({identityProvider: "https://id.ai",derivationOrigin: "https://xxxxx.icp0.io", // primary origin A});The primary origin (A) does not need
derivationOrigin: it is only required on alternative origins.
For full details, see the Internet Identity specification.
Common mistakes
- Using the wrong II URL per environment: local development must point to
http://id.ai.localhost:8000, mainnet tohttps://id.ai. Use thegetIdentityProviderUrlhelper (shown above) to switch based on hostname. fetch“Illegal invocation” in bundled builds: always passfetch: window.fetch.bind(window)toHttpAgent.create(). Without explicit binding, bundlers (Vite, webpack) extractfetchfromwindowand call it without the correctthiscontext.- Not awaiting
signIn()or skipping thetry/catch:authClient.signIn()returns a promise that rejects when the user closes the popup or authentication fails. Withoutawaitand acatch, those failures are silently swallowed. - Delegation expiry too long: the maximum is 30 days. Values above this are silently clamped, causing confusing session behavior. Use 8 hours for typical apps.
- 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: truein browser code: passrootKey: canisterEnv?.IC_ROOT_KEYfromsafeGetCanisterEnv()instead.shouldFetchRootKey: truefetches 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
AuthClientinstances: 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
requestAttributesmust come from a backend canister call so the canister can later verify that the bundle’simplicit:nonceis 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 themo:identity-attributesmixin and configuretrusted_attribute_signersandfrontend_originsinicp.yaml: it verifies the signer (and the origin, nonce, and freshness) for you. In Rust, there is no CDK wrapper yet, so always checkmsg_caller_info_signer()against the trusted issuer before readingmsg_caller_info_data().
Next steps
- Wallet integration for token-based authentication alternatives
- Frontend frameworks for framework-specific auth setup patterns
- Internet Identity specification for protocol details and the full alternative origins spec
- Security best practices for identity and trust fundamentals
- AuthClient API reference for the full
@icp-sdk/authAPI