For AI agents: Documentation index at /llms.txt

Skip to content

Access Management

Every canister method is callable by anyone on the internet. Without explicit access checks, any user or canister can invoke any of your public functions. This guide covers the patterns you need to restrict access.

Checklist

Use this as a quick reference when securing your canister:

  • Reject the anonymous principal (2vxsx-fae) in every authenticated endpoint
  • Check the caller inside each update method: not just in canister_inspect_message
  • Use the guard attribute (Rust) or guard functions (Motoko) to enforce access rules
  • Add a backup controller so you never lose canister access
  • Use canister_inspect_message only as a cycle-saving optimization, never as a security boundary

How caller identity works

When a canister receives a message, the network includes the caller’s principal. This identity is provided by the system: it cannot be forged or spoofed. You access it with:

  • Motoko: shared({ caller }) pattern on public functions
  • Rust: ic_cdk::api::msg_caller()

Every principal is one of these types:

TypeFormatExampleMeaning
UserVaries (self-authenticating)wo5qg-ysjaa-aaaaa-...Human with a cryptographic identity
Canister10 bytes, ends in -cairrkah-fqaaa-aaaaa-aaaaq-caiAnother canister making an inter-canister call
AnonymousFixed2vxsx-faeUnauthenticated caller: no identity
ManagementFixedaaaaa-aaIC management canister (system calls)

Reject anonymous callers

Any endpoint that requires authentication must reject the anonymous principal. Without this check, unauthenticated callers can invoke protected methods. If your canister uses the caller principal as an identity key (for balances, ownership, etc.), the anonymous principal becomes a shared identity anyone can use.

import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
// Inside persistent actor { ... }
func requireAuthenticated(caller : Principal) {
if (Principal.isAnonymous(caller)) {
Runtime.trap("anonymous caller not allowed");
};
};
public shared ({ caller }) func protectedAction() : async Text {
requireAuthenticated(caller);
"ok";
};

Owner and role-based access control

There is no built-in role system on ICP. You implement it yourself by tracking principals in your canister state.

The shared(msg) pattern on an actor class captures the deployer’s principal atomically. No separate init call, no front-running risk. Use transient for the owner since it gets recomputed from msg.caller on each install/upgrade.

import Principal "mo:core/Principal";
import Set "mo:core/pure/Set";
import Runtime "mo:core/Runtime";
shared(msg) persistent actor class MyCanister() {
transient let owner = msg.caller;
var admins : Set.Set<Principal> = Set.empty();
func requireOwner(caller : Principal) {
if (Principal.isAnonymous(caller)) {
Runtime.trap("anonymous caller not allowed");
};
if (caller != owner) {
Runtime.trap("caller is not the owner");
};
};
func requireAdmin(caller : Principal) {
if (Principal.isAnonymous(caller)) {
Runtime.trap("anonymous caller not allowed");
};
if (caller != owner and not Set.contains(admins, Principal.compare, caller)) {
Runtime.trap("caller is not an admin");
};
};
public shared ({ caller }) func addAdmin(newAdmin : Principal) : async () {
requireOwner(caller);
admins := Set.add(admins, Principal.compare, newAdmin);
};
public shared ({ caller }) func removeAdmin(admin : Principal) : async () {
requireOwner(caller);
admins := Set.remove(admins, Principal.compare, admin);
};
public shared ({ caller }) func adminAction() : async () {
requireAdmin(caller);
// ... protected logic
};
};

Always include admin revocation (removeAdmin). Missing revocation is a common source of bugs: once granted, admin access should be removable.

Controller checks

Controllers are the principals authorized to manage a canister (install code, change settings, stop/delete). The controller list is managed at the IC level, not inside your canister code.

Motoko provides Principal.isController to check if a principal is a controller of the current canister:

import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
// Inside persistent actor { ... }
public shared ({ caller }) func controllerOnly() : async () {
if (not Principal.isController(caller)) {
Runtime.trap("caller is not a controller");
};
// ...
};

Managing controllers with icp-cli:

Terminal window
# View current canister settings including controllers
icp canister settings show backend -e ic
# Add a backup controller
icp canister settings update backend --add-controller <backup-principal> -e ic
# Remove a controller (warning: removing yourself locks you out)
icp canister settings update backend --remove-controller <principal> -e ic

Always add a backup controller. If you lose the private key of the only controller, the canister becomes permanently unupgradeable: there is no recovery mechanism.

canister_inspect_message: cycle optimization only

canister_inspect_message is a hook that runs on a single replica before consensus. It can reject ingress messages early to save cycles on Candid decoding and execution. However, it is not a security boundary:

  • It runs on one node without consensus: a malicious boundary node can bypass it
  • It is never called for inter-canister calls, query calls, or management canister calls

Always duplicate real access checks inside each method. Use inspect_message only to reduce cycle waste from spam.

import Principal "mo:core/Principal";
// Inside persistent actor { ... }
// Method variants must match your public methods
system func inspect(
{
caller : Principal;
msg : {
#adminAction : () -> ();
#addAdmin : () -> Principal;
#removeAdmin : () -> Principal;
#protectedAction : () -> ();
}
}
) : Bool {
switch (msg) {
case (#adminAction _) { not Principal.isAnonymous(caller) };
case (#addAdmin _) { not Principal.isAnonymous(caller) };
case (#removeAdmin _) { not Principal.isAnonymous(caller) };
case (#protectedAction _) { not Principal.isAnonymous(caller) };
case (_) { true };
};
};

Debugging identity

When troubleshooting access control issues, it helps to know which principal your canister sees. A simple whoami endpoint returns the caller’s identity:

// Inside persistent actor { ... }
public shared ({ caller }) func whoami() : async Principal {
caller;
};

Call it to verify which identity is being used:

Terminal window
icp canister call backend whoami

Next steps