| name | internet-identity |
| description | Integrate Internet Identity authentication. Covers passkey and OpenID sign-in flows, delegation handling, principal-per-app isolation, and the /.well-known/ii-app-metadata document that shows your app's name, description, and logo on the sign-in screen. Use when adding sign-in, login, auth, passkeys, or Internet Identity to a frontend or canister. Do NOT use for wallet integration or ICRC signer flows — use wallet-integration instead. |
| license | Apache-2.0 |
| compatibility | icp-cli >= 0.2.4, Node.js >= 22, moc >= 1.6.0 |
| metadata | {"title":"Internet Identity","category":"Auth"} |
Internet Identity Authentication
What This Is
Internet Identity (II) is the Internet Computer's native authentication system. Users authenticate into II-powered apps either with passkeys stored in their devices or through OpenID accounts (e.g., Google, Apple, Microsoft) -- no usernames or passwords required. Each user gets a unique principal per app, preventing cross-app tracking.
Prerequisites
@icp-sdk/auth (>= 7.0.0), @icp-sdk/core (>= 5.3.0) (AttributesIdentity was added in core v5.3.0)
- For the Motoko backend example:
mo:identity-attributes >= 0.4.0 (mops) — the mixin that injects the two sign-in methods and verifies the bundle for you. It pulls in mo:core >= 2.5.0 and requires moc >= 1.6.0 for the include mixin.
Canister IDs
| Canister | ID | URL | Purpose |
|---|
| Internet Identity (backend) | rdmx6-jaaaa-aaaaa-aaadq-cai | | Manages user keys and authentication logic |
| Internet Identity (frontend) | uqzsh-gqaaa-aaaaq-qaada-cai | https://id.ai | Serves the II web app; identity provider URL points here |
Mistakes That Break Your Build
-
Using the wrong II URL for the environment. The identity provider URL must point to the frontend canister (uqzsh-gqaaa-aaaaq-qaada-cai), not the backend. Mainnet uses https://id.ai/authorize. Local-only II (when ii: true is set in icp.yaml) uses http://id.ai.localhost:8000/authorize. Both canister IDs are well-known and identical on mainnet and local replicas — hardcode them rather than doing a dynamic lookup.
-
Forgetting /authorize in the identityProvider URL. In @icp-sdk/auth 7.x the URL is used verbatim; the client does not append /authorize for you (it did in 5.x). Passing https://id.ai opens the II home page in the popup and never returns a delegation — the login button appears to do nothing. Always include the /authorize path.
-
Setting delegation expiry too long. Maximum delegation expiry is 30 days (2_592_000_000_000_000 nanoseconds). Longer values are silently clamped, which causes confusing session behavior. Use 8 hours for normal apps, 30 days maximum for "remember me" flows.
-
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.
-
Using shouldFetchRootKey or fetchRootKey() instead of the ic_env cookie. The ic_env cookie (set by the frontend canister or the Vite dev server) already contains the root key as IC_ROOT_KEY. Pass it via the rootKey option to HttpAgent.create() — this works in both local and production environments without environment branching. See the icp-cli skill's references/binding-generation.md for the pattern. Never call fetchRootKey() — it fetches the root key from the replica at runtime, which lets a man-in-the-middle substitute a fake key on mainnet.
-
Getting 2vxsx-fae as the principal after sign-in. That is the anonymous principal -- it means authentication silently failed. Common causes: wrong identityProvider URL passed to the AuthClient constructor (especially missing /authorize), an unhandled rejection from signIn(), or reading getIdentity() before signIn() resolved.
-
Passing principal as string to backend. The AuthClient gives you an Identity object. Backend canister methods receive the caller principal automatically via the IC protocol -- you do not pass it as a function argument. The caller principal is available on the backend via shared(msg) { msg.caller } in Motoko or ic_cdk::api::msg_caller() in Rust. For backend access control patterns, see the canister-security skill.
-
Adding derivationOrigin or ii-alternative-origins to handle the official gateway domains (ic0.app, icp0.io, icp.net). Internet Identity canonicalizes all three official canister gateway domains to one form during delegation (icp.net is the current default for new frontend canisters, replacing icp0.io), so a canister served at any of them produces the same principal. Do not add derivationOrigin or ii-alternative-origins configuration to handle this — it will break authentication. If a user reports getting a different principal, the cause is almost certainly a different passkey or device, not the domain. (A genuine second origin — a custom domain — is a different situation and does need this configuration: see "Serving an app at more than one origin".)
-
Generating the attribute nonce on the frontend. The nonce passed to requestAttributes MUST come from a backend canister call. A frontend-generated nonce defeats replay protection: the canister cannot verify that the bundle's implicit:nonce is one it actually issued. Have the backend mint and return the nonce from _internet_identity_sign_in_start (the mo:identity-attributes mixin provides it in Motoko; you write it in Rust), and check it against the bundle's implicit fields when the user calls _internet_identity_sign_in_finish.
-
Reading attribute data without verifying the signer. The IC verifies the signature, not the identity of the signer — any canister can produce a valid bundle. The trusted signer is rdmx6-jaaaa-aaaaa-aaadq-cai (Internet Identity). The check looks different per language:
- Motoko: use the
mo:identity-attributes mixin. include IdentityAttributes({ onVerified }) verifies the signer, origin, nonce, and freshness for you and runs onVerified only on a bundle that passes — configure trusted_attribute_signers and frontend_origins in icp.yaml (see "Backend: Reading Identity Attributes"). Don't hand-roll the ICRC-3 decode or the signer check on top of mo:core/CallerAttributes unless you need behavior the library doesn't cover.
- Rust: there is no CDK wrapper yet. Always check
msg_caller_info_signer() against the trusted issuer principal before reading msg_caller_info_data(). Skipping this lets an attacker canister forge attributes like email = "admin@you.com".
-
Substituting {tid} in the Microsoft scoped-key prefix. The microsoft OpenID provider URL is the literal string https://login.microsoftonline.com/{tid}/v2.0 — {tid} is part of the URL, not a tenant-ID placeholder you fill in. Bundle keys returned by scopedKeys({ openIdProvider: 'microsoft' }) look like openid:https://login.microsoftonline.com/{tid}/v2.0:email exactly, and the backend must look up that literal key. Replacing {tid} with a tenant GUID will silently miss every attribute lookup.
-
Treating email as verified. email and verified_email are distinct keys.
email is the raw email string from the user's II-linked account. II does not check it. Treat it as user-supplied input.
verified_email is the same email as email, but only present when the source OpenID provider (e.g., Google) marked it as verified and II surfaced that signal through.
Use verified_email for any access gating (admin allowlists, capability checks). Use email only for soft uses like contact info or mailing lists. Request both for fallback behaviour: both are returned with the same value when the source provider marked the email as verified, only email when it didn't.
-
Serving /.well-known/ii-app-metadata on the wrong origin, or without CORS. II reads app metadata from the origin identities are derived for — your validated derivationOrigin when the request sets one, the request's own origin otherwise. A document published only on the alternative origin the user visits is never fetched. The document and the logo it points at are both read cross-origin, and they fail differently: without Access-Control-Allow-Origin on the document none of your metadata is used (II falls back to its curated entry if it ships one for your app, and to your origin alone otherwise), while an unreadable logo costs you the logo alone — the name and description still render. See "Showing your app's name, description, and logo on the sign-in screen".
-
Assuming a bad field in ii-app-metadata is just dropped, or confusing a rejected logo with a rejected document. One field that fails validation invalidates the whole document: none of your metadata is applied, not just the offending field (II then falls back to its curated entry if it ships one for your app, and to your origin alone otherwise). name is capped at 40 Unicode code points and description at 120, counted on the value as served. logo straddles the two failure modes — a URL that is not on the same origin as the document fails document validation and takes the whole document down with it, and that includes your own canister on a sibling gateway domain, since II may fetch the document from any of ic0.app, icp0.io, or icp.net (write the URL relative) — while an SVG (image/svg+xml is not accepted; serve a raster copy), an oversized image, or one that cannot be fetched or decoded costs you the logo alone.
Using II during local development
Default: use mainnet II from your local network. Starting with icp-cli >= 0.2.4, the local network (pocket-ic, launched by icp-cli-network-launcher) is configured to trust the mainnet subnet's BLS signatures. Delegations signed by https://id.ai are accepted by your local replica, so both the sign-in flow and authenticated calls to a locally-deployed backend just work — no extra config in icp.yaml, no local II canister to manage, and the UI is the real one your users will see.
Point your frontend at https://id.ai/authorize unconditionally and you're done.
Fallback: deploy II locally
Only use this if you need fully-offline dev or want to test against a specific II build. Add ii: true to the local network in your icp.yaml:
networks:
- name: local
mode: managed
ii: true
This deploys the II canisters automatically when the local network is started. The II frontend will be available at http://id.ai.localhost:8000, and the identityProvider URL becomes http://id.ai.localhost:8000/authorize. No canister entry is needed in your project — II is not part of your project's canisters. For the full icp.yaml canister configuration, see the icp-cli and static-site skills.
Frontend: Vanilla JavaScript/TypeScript Sign-In Flow
This is framework-agnostic. Adapt the DOM manipulation to your framework.
import { AuthClient } from "@icp-sdk/auth/client";
import { HttpAgent, Actor } from "@icp-sdk/core/agent";
import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env";
const canisterEnv = safeGetCanisterEnv();
const authClient = new AuthClient({
identityProvider: "https://id.ai/authorize",
});
async function signIn() {
try {
const identity = await authClient.signIn({
maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000),
});
console.log("Signed in as:", identity.getPrincipal().toText());
return identity;
} catch (error) {
console.error("Sign-in failed:", error);
throw error;
}
}
async function signOut() {
await authClient.signOut();
}
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 });
}
async function init() {
if (authClient.isAuthenticated()) {
const identity = await authClient.getIdentity();
const actor = await createAuthenticatedActor(identity, canisterId, idlFactory);
}
}
init();
Serving an app at more than one origin
II derives a principal per origin, so https://<canister-id>.icp.net and https://shop.example.com are two different users to the same person. To keep one account per person, pick one origin as the derivation origin and list the others as alternative origins.
Pick the canister address as the derivation origin. Custom domains can be changed or dropped; the canister address cannot.
1. The alternative origin passes derivationOrigin. The primary origin does not — it is only set on the other origins.
const authClient = new AuthClient({
identityProvider: "https://id.ai/authorize",
derivationOrigin: "https://<canister-id>.icp.net",
});
2. The derivation origin's canister serves the list. Put the file at dir/.well-known/ii-alternative-origins:
{ "alternativeOrigins": ["https://shop.example.com"] }
A maximum of 100 alternative origins can be listed. Entries are origins — no trailing slashes and no paths.
Going over the cap is not a truncation: II rejects the entire list with has too many entries: To prevent misuse at most 100 alternative origins are allowed, so every alternative origin stops authenticating, not just the ones past the limit.
3. With @dfinity/static-site, add a _headers block. .well-known/ is uploaded automatically, but this file has no extension, so its media type is not application/json, and the certified-assets canister sets no CORS header by default. II needs both:
/.well-known/ii-alternative-origins
Content-Type: application/json
Access-Control-Allow-Origin: *
Do not reach for .ic-assets.json5 — that is the legacy asset canister's config file, and the static-site recipe does not read or even upload it, so the headers would silently never apply. See the static-site skill.
Order matters. Pin the derivation origin before an origin has users. Repointing an origin that has already collected sign-ins orphans every account made under it.
Showing your app's name, description, and logo on the sign-in screen
By default the II sign-in screens identify your app by its origin alone. To have II also show a name, a short tagline, and a logo, serve a JSON document at /.well-known/ii-app-metadata. This is permissionless — there is no list to join and no approval step — and it supersedes the curated entry II still ships for a small set of known apps.
{
"name": "Example App",
"description": "A short tagline shown on the sign-in screen",
"logo": "/logo.png"
}
Publish it on the derivation origin, not on every origin. II fetches the document from the origin identities are derived for: your derivationOrigin once validated when the auth request sets one, and the request's own origin otherwise. Publish it once on the derivation origin and every alternative origin that origin certifies is presented with the same name, description, and logo — there is nothing to keep in sync. A copy served only on the alternative origin the user actually visits is never read.
All three fields are optional and unknown fields are ignored, so a document stays valid as II adds fields. The rules that decide whether yours is used:
name is at most 40 characters and description at most 120, counted in Unicode code points on the value as served — before whitespace collapsing, which is display-only and never rescues an over-long value.
- Each field must contain at least one visible character. A field holding only whitespace or invisible characters is rejected, not treated as absent.
- Control characters (other than the ASCII whitespace
\t, \n, \v, \f, \r), U+FEFF, and the bidirectional embeddings and overrides U+202A–U+202E are rejected: they can make rendered text read differently from what it contains. The characters mixed-direction and non-Latin names legitimately need are accepted — the marks U+200E, U+200F, U+061C, the isolates U+2066–U+2069, and the zero-width characters U+200B–U+200D — but isolates must be balanced: a field must close every isolate it opens and close none it did not open.
- One bad field invalidates the whole document, which is then ignored; the offending field is not dropped on its own. None of your document is applied rather than half of it; II then falls back to its curated entry if it ships one for your app, and to your origin alone otherwise. II logs which field is at fault to the browser console — check the console on the sign-in screen when metadata does not appear. A document carrying no field II recognises is likewise ignored; a valid one replaces the curated fallback entry wholesale.
logo must be a raster image URL on the same origin as the document (relative URLs resolve against it), served as image/png, image/jpeg, image/webp, image/gif, or image/avif, at most 1 MiB, and at most 4096 pixels per axis. image/svg+xml is rejected — serve a rasterised copy of a vector logo. II downloads the image (it is never hotlinked), redraws it at up to 512 pixels on its longest side (an animated image is flattened to its first frame), and renders that copy, so a roughly square PNG or WebP of about 512 pixels is the right thing to ship. Write the URL relative (/logo.png): II normalizes a canister gateway origin onto ic0.app and tries its icp0.io and icp.net twins in turn, so the document may be fetched from a sibling gateway domain of the same canister — and the same-origin check below runs against whichever one answered. An absolute URL pinned to one of them is cross-origin at the other two.
- Only the shape of
logo — a non-empty URL on the document's own origin — is part of the validation above. Once it passes, a logo that cannot be fetched or decoded, or that breaks the content-type, size, or dimension rules, costs you the logo alone: the name and description still render.
- The document must not exceed 8 KiB, must be answered with
200, and must not redirect (II follows redirects for neither the document nor the logo). II requests it without credentials and gives up after 10 seconds.
Both the document and the logo are read cross-origin, so both need CORS headers. With @dfinity/static-site, extend the same _headers file used for alternative origins:
/.well-known/ii-app-metadata
Content-Type: application/json
Access-Control-Allow-Origin: *
/logo.png
Access-Control-Allow-Origin: *