For AI agents: Documentation index at /llms.txt

Skip to content

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:

  1. Your frontend opens an II popup window.
  2. The user authenticates with a passkey or OpenID provider.
  3. II creates a delegation identity: a temporary key pair that can sign messages on behalf of the user’s master key.
  4. Your frontend receives this delegation and uses it to sign canister calls.
  5. 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: true

Install frontend packages

Terminal window
npm install @icp-sdk/auth @icp-sdk/core

Frontend 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";
}
return "https://id.ai";
}

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 session
if (authClient.isAuthenticated()) {
const identity = await authClient.getIdentity();
// Restore session: create agent and actor with this identity
}
// Sign in
async 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 out
async 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. 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.

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.

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";
async function registerWithEmail() {
// 1. Backend issues a nonce scoped to this registration
const anonymousAgent = await HttpAgent.create();
const backend = Actor.createActor(backendIdl, {
agent: anonymousAgent,
canisterId,
});
const nonce = await backend.registerBegin();
// 2. Run sign-in and the attribute request in parallel.
// The user sees a single Internet Identity interaction.
const signInPromise = authClient.signIn();
const attributesPromise = authClient.requestAttributes({
keys: ["email"],
nonce,
});
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") },
});
// 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();
}

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: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.

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",
});
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.
const attributesPromise = authClient.requestAttributes({
keys: scopedKeys({ openIdProvider: "google" }),
nonce,
});

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)
};
};

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

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<system>() 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<u8> returns the raw bundle and ic_cdk::api::msg_caller_info_signer() -> Option<Principal> returns the signer. There is no CDK wrapper for the trusted-signer check yet, so check the signer explicitly before reading the data.

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:

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.

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 for this user and action.
  • 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.
import CallerAttributes "mo:core/CallerAttributes";
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)];
};
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 _ {} };
};
};
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<system>() 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 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.
let ?email = lookupText(entries, "email") else Runtime.trap("missing email");
"Registered " # Principal.toText(caller) # " with email " # email
};
};

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.

Local development

Start the local network and deploy. With ii: true in your icp.yaml, icp-cli deploys a local Internet Identity canister automatically:

Terminal window
icp network start
icp deploy

icp-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:

Terminal window
# Test as the default identity (authenticated)
icp canister call backend whoAmI
# Test as anonymous using --identity to avoid changing your global default
icp 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:

Terminal window
icp deploy -e ic

Alternative 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:

  1. On the primary origin (A): Create a file at .well-known/ii-alternative-origins listing the alternative domains:

    {
    "alternativeOrigins": ["https://www.yourcustomdomain.com"]
    }

    A maximum of 10 alternative origins can be listed. No trailing slashes or paths.

  2. Configure the asset canister to serve the .well-known directory. Add an .ic-assets.json5 in 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
    }
    ]
  3. On the alternative origin (B): Set the derivationOrigin on the AuthClient constructor 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 to https://id.ai. Use the getIdentityProviderUrl helper (shown above) to switch based on hostname.
  • fetch “Illegal invocation” in bundled builds: always pass fetch: window.fetch.bind(window) to HttpAgent.create(). Without explicit binding, bundlers (Vite, webpack) extract fetch from window and call it without the correct this context.
  • Not awaiting signIn() or skipping the try/catch: authClient.signIn() returns a promise that rejects when the user closes the popup or authentication fails. Without await and a catch, 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: 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<system>() 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().

Next steps