Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 258 additions & 33 deletions docs/guides/authentication/internet-identity.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Internet Identity"
description: "Integrate passkey-based authentication with Internet Identity for frontend login, backend caller verification, and session management"
description: "Integrate passkey-based authentication with Internet Identity for frontend sign-in, backend caller verification, and session management"
sidebar:
order: 1
---
Expand All @@ -9,7 +9,7 @@ import { Tabs, TabItem } from '@astrojs/starlight/components';

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 login to your frontend, and verifying callers in your backend.
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

Expand Down Expand Up @@ -46,7 +46,7 @@ npm install @icp-sdk/auth @icp-sdk/core

## Frontend integration

The `AuthClient` from `@icp-sdk/auth` handles the full login flow: opening the II popup, receiving the delegation, and managing session persistence.
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

Expand Down Expand Up @@ -77,50 +77,61 @@ function getIdentityProviderUrl() {
}
```

### Login, logout, and session check
### Sign in, sign out, and session check

Create a single `AuthClient` instance on page load and reuse it for all operations:
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:

```javascript
// Create the auth client (once, on page load)
const authClient = await AuthClient.create();
const authClient = new AuthClient({
identityProvider: getIdentityProviderUrl(),
});

// Check for existing session
const isAuthenticated = await authClient.isAuthenticated();
if (isAuthenticated) {
const identity = authClient.getIdentity();
if (authClient.isAuthenticated()) {
const identity = await authClient.getIdentity();
// Restore session: create agent and actor with this identity
}

// Login
async function login() {
return new Promise((resolve, reject) => {
authClient.login({
identityProvider: getIdentityProviderUrl(),
// Sign in
async function signIn() {
try {
const identity = await authClient.signIn({
maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours
onSuccess: () => {
const identity = authClient.getIdentity();
console.log("Logged in as:", identity.getPrincipal().toText());
resolve(identity);
},
onError: (error) => {
console.error("Login failed:", error);
reject(error);
},
});
});
console.log("Signed in as:", identity.getPrincipal().toText());
return identity;
} catch (error) {
console.error("Sign-in failed:", error);
throw error;
}
}

// Logout
async function logout() {
await authClient.logout();
// 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'`:

```javascript
const authClient = new AuthClient({
identityProvider: getIdentityProviderUrl(),
openIdProvider: "google",
});
```

The rest of the flow (`signIn`, `getIdentity`, `signOut`) is unchanged.

### Create an authenticated agent

After login, create an `HttpAgent` using the delegation identity. The agent signs all subsequent canister calls with the user's delegated key:
After sign-in, create an `HttpAgent` using the delegation identity. The agent signs all subsequent canister calls with the user's delegated key:

```javascript
async function createAuthenticatedActor(identity, canisterId, idlFactory) {
Expand All @@ -138,6 +149,84 @@ async function createAuthenticatedActor(identity, canisterId, idlFactory) {
`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.

```typescript
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:

```typescript
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.
Expand Down Expand Up @@ -224,6 +313,141 @@ async fn protected_async_action() -> String {
}
```

### Read identity attributes

When the frontend wraps an identity with `AttributesIdentity`, every call carries a verified attribute bundle. Read it on the backend with `msg_caller_info_data` (Rust) or `Prim.callerInfoData` (Motoko). Always verify the signer first: by itself the bundle is signed by *some* canister, and any canister could have signed an arbitrary one. Trust it only when the signer is the Internet Identity backend (`rdmx6-jaaaa-aaaaa-aaadq-cai`).

The bundle is Candid-encoded as an [ICRC-3 Value](../../reference/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: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`.

<Tabs syncKey="lang">
<TabItem label="Motoko">

```motoko
import Prim "mo:prim";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";

persistent actor {
let iiPrincipal = Principal.fromText("rdmx6-jaaaa-aaaaa-aaadq-cai");

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, trapping if the signer is not II.
func iiAttributes() : [(Text, Icrc3Value)] {
let signer = Prim.callerInfoSigner<system>();
if (signer.size() == 0 or Principal.fromBlob(signer) != iiPrincipal) {
Runtime.trap("Untrusted attribute signer");
};
let data = Prim.callerInfoData<system>();
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
};
};
```

</TabItem>
<TabItem label="Rust">

```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::update;

const II_PRINCIPAL: &str = "rdmx6-jaaaa-aaaaa-aaadq-cai";

#[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,
})
}

// Returns the verified attribute entries, trapping if the signer is not II.
fn ii_attributes() -> Vec<(String, Icrc3Value)> {
let trusted = Principal::from_text(II_PRINCIPAL).unwrap();
if msg_caller_info_signer() != Some(trusted) {
ic_cdk::trap("Untrusted attribute signer");
}
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"),
}
}

#[update]
fn register_finish() -> String {
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"); }

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

let email = lookup_text(&entries, "email")
.unwrap_or_else(|| ic_cdk::trap("missing email"));
format!("Registered {} with email {}", caller, email)
}
```

</TabItem>
</Tabs>

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

## Local development

Start the local network and deploy. With `ii: true` in your `icp.yaml`, icp-cli deploys a local Internet Identity canister automatically:
Expand Down Expand Up @@ -291,13 +515,12 @@ To keep principals consistent across your own custom domains, configure **altern
]
```

3. **On the alternative origin (B):** Set the `derivationOrigin` in your login call to point back to the primary origin:
3. **On the alternative origin (B):** Set the `derivationOrigin` on the `AuthClient` constructor to point back to the primary origin:

```javascript
authClient.login({
const authClient = new AuthClient({
identityProvider: "https://id.ai",
derivationOrigin: "https://xxxxx.icp0.io", // primary origin A
onSuccess: () => { /* ... */ },
});
```

Expand All @@ -309,11 +532,13 @@ For full details, see the [Internet Identity specification](../../reference/inte

- **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.
- **Missing `onSuccess`/`onError` callbacks**: `authClient.login()` requires both. Without them, login failures are silently swallowed.
- **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**: `msg_caller_info_data` (`Prim.callerInfoData` in Motoko) returns whatever bundle the caller provided. The IC system checks the signature, not the identity of the signer. If you skip the `msg_caller_info_signer` check (or compare it against the wrong principal), any canister can mint its own bundle and your method will read attacker-controlled values. Verify the signer matches `rdmx6-jaaaa-aaaaa-aaadq-cai` (Internet Identity) before trusting the bundle.

## Next steps

Expand All @@ -325,4 +550,4 @@ For full details, see the [Internet Identity specification](../../reference/inte

{/* 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 */}
{/* 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-auth (README.md, src/client/auth-client.ts); dfinity/cdk-rs (ic-cdk/src/api.rs msg_caller_info_data/signer); caffeinelabs/motoko (src/prelude/prim.mo callerInfoData/Signer, test/run-drun/caller-info/caller-info.mo) */}
Loading