Internet Identity Authentication
What This Is
Internet Identity (II) is the Internet Computer's native authentication system. Users authenticate with passkeys, WebAuthn, or hardware security keys -- no passwords, no seed phrases, no third-party identity providers. Each user gets a unique principal per dApp, preventing cross-app tracking.
Prerequisites
- icp-cli >= 0.1.0 (
brew install dfinity/tap/icp-cli) - Node.js >= 18 (for frontend)
@icp-sdk/authnpm package (>= 5.0.0)@icp-sdk/corenpm package (>= 5.0.0)
Canister IDs
| Environment | Canister ID | URL |
|---|---|---|
| Mainnet | rdmx6-jaaaa-aaaaa-aaadq-cai |
https://identity.ic0.app (also https://identity.internetcomputer.org) |
| Local | Assigned on deploy | http://<local-canister-id>.localhost:4943 |
Mistakes That Break Your Build
Not rejecting anonymous principal. The anonymous principal
2vxsx-faeis sent when a user is not authenticated. If your backend does not explicitly reject it, unauthenticated users can call protected endpoints. ALWAYS checkPrincipal.isAnonymous(caller)and reject.Using the wrong II URL for the environment. Local development must point to
http://<local-ii-canister-id>.localhost:4943(this canister ID is different from mainnet). Mainnet must usehttps://identity.ic0.app. Hardcoding one breaks the other. The local II canister ID is assigned dynamically when you runicp deploy internet_identity-- read it fromprocess.env.CANISTER_ID_INTERNET_IDENTITY(note: this auto-generated env var may work differently in icp-cli than it did in the legacy tooling; verify your build tooling picks it up) or your canister_ids.json (path may differ in icp-cli projects compared to the legacy.icp/local/canister_ids.jsonlocation).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 handling auth callbacks. The
authClient.login()call requiresonSuccessandonErrorcallbacks. Without them, login failures are silently swallowed.Defensive practice: bind
msg_caller()before.awaitin Rust. The current ic-cdk executor preserves the caller across.awaitpoints, but capturing it early guards against future executor changes. Always bindlet caller = ic_cdk::api::msg_caller();at the top of async update functions.Passing principal as string to backend. The
AuthClientgives you anIdentityobject. Backend canister methods receive the caller principal automatically via the IC protocol -- you do not pass it as a function argument. Useshared(msg) { msg.caller }in Motoko oric_cdk::api::msg_caller()in Rust.Not calling
agent.fetchRootKey()in local development. Without this, certificate verification fails on localhost. Never call it in production -- it's a security risk on mainnet.Storing auth state in
thread_local!without stable storage (Rust) --thread_local! { RefCell<T> }is heap memory, wiped on every canister upgrade. UseStableCellfromic-stable-structuresfor any state that must persist across upgrades, especially ownership/auth data.
Implementation
icp.yaml Configuration
For local development, download the II canister WASM from the dfinity/internet-identity releases. Place the .wasm.gz and .did files in your project.
canisters:
internet_identity:
type: custom
candid: deps/internet-identity/internet_identity.did
wasm: deps/internet-identity/internet_identity_dev.wasm.gz
build: ""
remote:
id:
ic: rdmx6-jaaaa-aaaaa-aaadq-cai
The remote.id.ic field tells icp to skip deploying this canister on mainnet (use the existing one). Locally, icp deploys the provided WASM.
Frontend: Vanilla JavaScript/TypeScript Login 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";
// 1. Create the auth client
const authClient = await AuthClient.create();
// 2. Determine II URL based on environment
// The local II canister gets a different canister ID each time you deploy it.
// Pass it via an environment variable at build time (e.g., Vite: import.meta.env.VITE_II_CANISTER_ID).
function getIdentityProviderUrl() {
const host = window.location.hostname;
const isLocal = host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost");
if (isLocal) {
// Read from env variable set during build, or from canister_ids.json
// For Vite: define VITE_II_CANISTER_ID in .env.local
// For webpack: use DefinePlugin with process.env.II_CANISTER_ID
const iiCanisterId = import.meta.env.VITE_II_CANISTER_ID
?? process.env.CANISTER_ID_INTERNET_IDENTITY // auto-generated by build tooling (verify this works with icp-cli)
?? "be2us-64aaa-aaaaa-qaabq-cai"; // fallback -- replace with your actual local II canister ID
return `http://${iiCanisterId}.localhost:4943`;
}
return "https://identity.ic0.app";
}
// 3. Login
async function login() {
return new Promise((resolve, reject) => {
authClient.login({
identityProvider: getIdentityProviderUrl(),
maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours in nanoseconds
onSuccess: () => {
const identity = authClient.getIdentity();
const principal = identity.getPrincipal().toText();
console.log("Logged in as:", principal);
resolve(identity);
},
onError: (error) => {
console.error("Login failed:", error);
reject(error);
},
});
});
}
// 4. Create an authenticated agent and actor
async function createAuthenticatedActor(identity, canisterId, idlFactory) {
const isLocal = window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1" ||
window.location.hostname.endsWith(".localhost");
const agent = await HttpAgent.create({
identity,
host: isLocal ? "http://localhost:4943" : "https://icp-api.io",
...(isLocal && { shouldFetchRootKey: true, verifyQuerySignatures: false }),
});
return Actor.createActor(idlFactory, { agent, canisterId });
}
// 5. Logout
async function logout() {
await authClient.logout();
// Optionally reload or reset UI state
}
// 6. Check if already authenticated (on page load)
const isAuthenticated = await authClient.isAuthenticated();
if (isAuthenticated) {
const identity = authClient.getIdentity();
// Restore session -- create actor, update UI
}
Backend: Motoko
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
persistent actor {
// Owner/admin principal
var owner : ?Principal = null;
// Helper: reject anonymous callers
func requireAuth(caller : Principal) : () {
if (Principal.isAnonymous(caller)) {
Runtime.trap("Anonymous principal not allowed. Please authenticate.");
};
};
// Initialize the first authenticated caller as owner
public shared (msg) func initOwner() : async Text {
requireAuth(msg.caller);
switch (owner) {
case (null) {
owner := ?msg.caller;
"Owner set to " # Principal.toText(msg.caller);
};
case (?_existing) {
"Owner already initialized";
};
};
};
// Owner-only endpoint example
public shared (msg) func adminAction() : async Text {
requireAuth(msg.caller);
switch (owner) {
case (?o) {
if (o != msg.caller) {
Runtime.trap("Only the owner can call this function.");
};
"Admin action performed";
};
case (null) {
Runtime.trap("Owner not set. Call initOwner first.");
};
};
};
// Public query: anyone can call, but returns different data for authenticated users
public shared query (msg) func whoAmI() : async Text {
if (Principal.isAnonymous(msg.caller)) {
"You are not authenticated (anonymous)";
} else {
"Your principal: " # Principal.toText(msg.caller);
};
};
// Getting caller principal in shared functions
// ALWAYS use `shared (msg)` or `shared ({ caller })` syntax:
public shared ({ caller }) func protectedEndpoint(data : Text) : async Bool {
requireAuth(caller);
// Use `caller` for authorization checks
true;
};
};
Backend: Rust
# Cargo.toml
[package]
name = "ii_backend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
ic-cdk = "0.19"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
ic-stable-structures = "0.7"
use candid::Principal;
use ic_cdk::{query, update};
use ic_stable_structures::{DefaultMemoryImpl, StableCell};
use std::cell::RefCell;
thread_local! {
// Principal::anonymous() is used as the "not set" sentinel.
// Option<Principal> does not implement Storable, so we store Principal directly.
static OWNER: RefCell<StableCell<Principal, DefaultMemoryImpl>> = RefCell::new(
StableCell::init(DefaultMemoryImpl::default(), Principal::anonymous())
);
}
/// Reject anonymous principal. Call this at the top of every protected endpoint.
fn require_auth() -> Principal {
let caller = ic_cdk::api::msg_caller();
if caller == Principal::anonymous() {
ic_cdk::trap("Anonymous principal not allowed. Please authenticate.");
}
caller
}
#[update]
fn init_owner() -> String {
// Defensive: capture caller before any .await calls.
let caller = require_auth();
OWNER.with(|owner| {
let mut cell = owner.borrow_mut();
let current = *cell.get();
if current == Principal::anonymous() {
cell.set(caller);
format!("Owner set to {}", caller)
} else {
"Owner already initialized".to_string()
}
})
}
#[update]
fn admin_action() -> String {
let caller = require_auth();
OWNER.with(|owner| {
let cell = owner.borrow();
let current = *cell.get();
if current == Principal::anonymous() {
ic_cdk::trap("Owner not set. Call init_owner first.");
} else if current == caller {
"Admin action performed".to_string()
} else {
ic_cdk::trap("Only the owner can call this function.");
}
})
}
#[query]
fn who_am_i() -> String {
let caller = ic_cdk::api::msg_caller();
if caller == Principal::anonymous() {
"You are not authenticated (anonymous)".to_string()
} else {
format!("Your principal: {}", caller)
}
}
// For async functions, capture caller before await as defensive practice:
#[update]
async fn protected_async_action() -> String {
let caller = require_auth(); // Capture before any await
let _result = some_async_operation().await;
format!("Action completed by {}", caller)
}
Rust defensive practice: Bind let caller = ic_cdk::api::msg_caller(); at the top of async update functions. The current ic-cdk executor preserves caller across .await points via protected tasks, but capturing it early guards against future executor changes.
Deploy & Test
Local Deployment
# Start the local replica
icp network start -d
# Deploy II canister and your backend
icp deploy internet_identity
icp deploy backend
# Verify II is running
icp canister status internet_identity
Mainnet Deployment
# II is already on mainnet -- only deploy your canisters
icp deploy -e ic backend
Verify It Works
# 1. Check II canister is running
icp canister status internet_identity
# Expected: Status: Running
# 2. Test anonymous rejection from CLI
icp canister call backend adminAction
# Expected: Error containing "Anonymous principal not allowed"
# 3. Test whoAmI as anonymous
icp canister call backend whoAmI
# Expected: ("You are not authenticated (anonymous)")
# 4. Test whoAmI as authenticated identity
icp canister call backend whoAmI
# Expected: ("Your principal: <your-identity-principal>")
# Note: icp CLI calls use the current identity, not anonymous,
# unless you explicitly use --identity anonymous
# 5. Test with explicit anonymous identity
icp identity use anonymous
icp canister call backend adminAction
# Expected: Error containing "Anonymous principal not allowed"
icp identity use default # Switch back
# 6. Open II in browser for local dev
# Visit: http://<internet_identity_canister_id>.localhost:4943
# You should see the Internet Identity login page