- 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` (>= 9.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
1. **Using the wrong II URL for the environment.** `authorizeUrl` 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.
2. **Passing `identityProvider` as a URL string, or naming only half of it.** In 9.x it is an object — `{ authorizeUrl, canisterId }` — and both fields are required together: the page a ceremony renders at and the canister that mints delegations are separate facts, and neither is derived from the other. A string or a `URL` throws a `TypeError`. Omit the option entirely to get mainnet Internet Identity, which is what most apps want. The URL is used verbatim, so include the `/authorize` path: `https://id.ai` opens the II home page and never returns a delegation.
3. **Treating `maxTimeToLive` as the lifetime of the key the frontend signs with.** In 9.x it bounds the **session** at Internet Identity, and `maxTimeToIdle` ends a session nobody has used; the delegation your calls are signed with is short-lived and replaced for you. Leave both unset unless the app has a policy of its own — the provider applies seven days of idleness and thirty days in total. Bound them where the data is sensitive, not to keep key material fresh.
4. **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.
5. **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.
6. **Getting `2vxsx-fae` as the principal after sign-in.** That is the anonymous principal -- it means authentication silently failed. Common causes: a wrong `authorizeUrl` on the `AuthClient` constructor (especially missing `/authorize`), an unhandled rejection from `signIn()`, or reading `getIdentity()` before `signIn()` resolved. Note that `getIdentity()` throws `SessionNotHeldError` rather than handing back an anonymous identity when a sign-in exists that this origin holds no credential for.
7. **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.
8. **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".)
9. **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`.
10. **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"`.
11. **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.
12. **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.
13. **Sharing a cookie domain without a shared `derivationOrigin`.** Sibling subdomains only share a sign-in when they share a principal, and principals are per origin: without one derivation origin authorized for all of them, the shared record names an account the reading origin can never hold, so `/reauth` bounces the user back forever. Set the derivation origin first, then the cookie domain.
14. **A silent re-issue without `hint`, on the default transport, or to an undeclared callback.** `prompt: 'none'` asks the provider to answer from the session it holds. Without `hint`, a provider holding more than one session refuses rather than guessing — `InteractionRequiredError` with `reason` `account_selection_required` — so what you lose is the resume, not the user's identity: a mint for an unexpected account is rejected client-side as `AccountMismatchError`. The re-issue also runs on page load with no user gesture, so the default `window` transport is popup-blocked: use `transport: 'redirect'` on a route of its own, and declare that route in the origin's `/.well-known/ii-auth-callbacks`, or the redirect never comes back.
15. **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".
16. **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.
Construct the client with no `identityProvider` at all: mainnet Internet Identity is what it defaults to, 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`:
```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`, so the client is constructed with `identityProvider: { authorizeUrl: 'http://id.ai.localhost:8000/authorize', canisterId: 'rdmx6-jaaaa-aaaaa-aaadq-cai' }` — the canister id is the same locally, since system canisters keep their mainnet ids on the local network. 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.
```javascript
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 frontend canister or Vite dev server).
// Contains the root key and canister IDs — works in both local and production.
const canisterEnv = safeGetCanisterEnv();
// Mainnet Internet Identity is the default, so no identityProvider is needed:
// pocket-ic (icp-cli >= 0.2.4) trusts mainnet subnet signatures, so this works
// from local dev too. Pass { authorizeUrl, canisterId } only for a local II
// (`ii: true` in icp.yaml) or another deployment; both halves are required
// together, and a bare URL string throws.
//
// derivationOrigin, and openIdProvider for one-click sign-in
// ('google' | 'apple' | 'microsoft'), are also constructor options.
//
// Several clients may share an origin and read the same sign-in, so construct
// one where you need it and dispose of it when that view goes away.
const authClient = new AuthClient();
// Sign in: signIn() returns the new Identity directly and rejects if the user
// closes the popup or authentication fails. The session's bounds
// (maxTimeToIdle, maxTimeToLive) are optional; unset means Internet Identity's
// own, currently seven days idle and thirty days in total.
async function signIn() {
try {
const identity = await authClient.signIn();
console.log("Signed in as:", identity.getPrincipal().toText());
return identity;
} catch (error) {
console.error("Sign-in failed:", error);
throw error;
}
}
// Sign out, which ends the session at Internet Identity: every tab of this
// origin is signed out and the session cannot be resumed. Nothing to reset or
// reload here — the state changes, so the subscription below re-renders.
async function signOut() {
await authClient.signOut();
}
// Create an authenticated agent and actor.
// Uses rootKey from the ic_env cookie — no shouldFetchRootKey or environment branching needed.
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 });
}
// Initialization — wraps async setup in a function so this code works with
// any bundler target (Vite defaults to es2020 which lacks top-level await).
async function init() {
// isAuthenticated() is sync; getIdentity() is async.
if (authClient.isAuthenticated()) {
const identity = await authClient.getIdentity();
const actor = await createAuthenticatedActor(identity, canisterId, idlFactory);
// Use actor to call backend methods
}
// Re-render when who is signed in here changes, including in another tab:
// getStatus() is 'signed-in' | 'signed-in-elsewhere' | 'expired' |
// 'signed-out', and the last three each want a different screen.
authClient.subscribe(() => render(authClient.getStatus()));
}
init();
```
### The client's lifecycle
One client for the page, or one per component: both work, and they read and write
the same sign-in.
```javascript
// Page-lifetime: one client for the app, nothing to dispose. Views come and go,
// so each hands back the teardown for its own listener.
const authClient = new AuthClient();
function watchHeader() {
const unsubscribe = authClient.subscribe(() => render(authClient.getStatus()));
return unsubscribe; // when the header goes; the client carries on
}
// Component-lifetime: the client belongs to the view, so it goes with the view.
function openReauthDialog(principal) {
const client = new AuthClient({ prompt: "none", hint: principal });
return () => client.dispose(); // covers its subscription, and is not a sign-out
}
```
`prompt: "none"` with `hint` is a silent re-issue, which an app wants when a
sibling subdomain is already signed in: see "Sharing a sign-in across sibling
subdomains" below.
The client is browser-only, so under a server-rendering framework whatever owns it
must be client-rendered.
### 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.
```js
const authClient = new AuthClient({
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`:
```json
{ "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.
### Sharing a sign-in across sibling subdomains
`chat.example.com` and `hr.example.com` can share one sign-in: sign in on one and
the others are signed in without a second visit to the provider, and signing out
on one signs the user out on all of them.
This builds on the section above. Every app must derive from **one** derivation
origin, listed in that origin's `ii-alternative-origins`, or each subdomain gets
its own principal and there is nothing to share. A shared cookie does not change
that. On top of it, two things:
**1. Share the record.** Every app builds its client with the same cookie domain,
so a sign-in on one writes a record the others read:
```javascript
import { AuthClient, CookieStateStorage, InteractionRequiredError } from "@icp-sdk/auth/client";
const clientOptions = {
derivationOrigin: "https://auth.example.com",
stateStorage: new CookieStateStorage({ domain: "example.com" }),
};
Voir sur GitHub