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
guardattribute (Rust) or guard functions (Motoko) to enforce access rules - Add a backup controller so you never lose canister access
- Use
canister_inspect_messageonly 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:
| Type | Format | Example | Meaning |
|---|---|---|---|
| User | Varies (self-authenticating) | wo5qg-ysjaa-aaaaa-... | Human with a cryptographic identity |
| Canister | 10 bytes, ends in -cai | rrkah-fqaaa-aaaaa-aaaaq-cai | Another canister making an inter-canister call |
| Anonymous | Fixed | 2vxsx-fae | Unauthenticated caller: no identity |
| Management | Fixed | aaaaa-aa | IC 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"; };use ic_cdk::update;use ic_cdk::api::msg_caller;use candid::Principal;
fn require_authenticated() -> Result<(), String> { if msg_caller() == Principal::anonymous() { return Err("anonymous caller not allowed".to_string()); } Ok(())}
#[update(guard = "require_authenticated")]fn protected_action() -> String { "ok".to_string()}The Rust guard attribute runs the check before the method body executes. If the guard returns Err, the call is rejected. This is more robust than calling guard functions inside the method: you cannot forget to add it. Multiple guards can be chained:
#[update(guard = "require_authenticated", guard = "require_admin")]fn admin_action() { // both guards passed}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 };};use ic_cdk::{init, update};use ic_cdk::api::msg_caller;use candid::Principal;use std::cell::RefCell;
thread_local! { static OWNER: RefCell<Principal> = RefCell::new(Principal::anonymous()); static ADMINS: RefCell<Vec<Principal>> = RefCell::new(vec![]);}
fn require_authenticated() -> Result<(), String> { if msg_caller() == Principal::anonymous() { return Err("anonymous caller not allowed".to_string()); } Ok(())}
fn require_owner() -> Result<(), String> { require_authenticated()?; OWNER.with(|o| { if msg_caller() != *o.borrow() { return Err("caller is not the owner".to_string()); } Ok(()) })}
fn require_admin() -> Result<(), String> { require_authenticated()?; let caller = msg_caller(); let is_authorized = OWNER.with(|o| caller == *o.borrow()) || ADMINS.with(|a| a.borrow().contains(&caller)); if !is_authorized { return Err("caller is not an admin".to_string()); } Ok(())}
#[init]fn init(owner: Principal) { OWNER.with(|o| *o.borrow_mut() = owner);}// Unlike Motoko's shared(msg) pattern which captures the deployer automatically,// the Rust #[init] requires passing the owner explicitly at deploy time:// icp canister deploy backend --argument '(principal "your-principal-here")'
#[update(guard = "require_owner")]fn add_admin(new_admin: Principal) { ADMINS.with(|a| a.borrow_mut().push(new_admin));}
#[update(guard = "require_owner")]fn remove_admin(admin: Principal) { ADMINS.with(|a| a.borrow_mut().retain(|p| p != &admin));}
#[update(guard = "require_admin")]fn admin_action() { // ... protected logic: guard already validated caller}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"); }; // ... };In Rust, there is no built-in is_controller function: checking controllers requires an async call to the management canister. See inter-canister calls for inter-canister call patterns.
Managing controllers with icp-cli:
# View current canister settings including controllersicp canister settings show backend -e ic
# Add a backup controllericp 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 icAlways 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 }; }; };use ic_cdk::api::{accept_message, msg_caller, msg_method_name};use candid::Principal;
#[ic_cdk::inspect_message]fn inspect_message() { let method = msg_method_name(); match method.as_str() { "admin_action" | "add_admin" | "remove_admin" | "protected_action" => { if msg_caller() != Principal::anonymous() { accept_message(); } // Silently reject anonymous: saves cycles } _ => accept_message(), }}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; };use ic_cdk::query;use ic_cdk::api::msg_caller;use candid::Principal;
#[query]fn whoami() -> Principal { msg_caller()}Call it to verify which identity is being used:
icp canister call backend whoamiNext steps
- Security concepts: understand the IC security model
- Canister settings: configure controllers and freezing thresholds
- DoS prevention: rate limiting as an access control mechanism