User Attributes
Prerequisites
This skill assumes a working Internet Identity sign-in built with @icp-sdk/auth: load the user-sign-in skill first. The attribute bundle travels alongside a delegation, so there is nothing here to request without one.
The frontend_origins value configured below must be the origin identities are derived for, the primary origin where the app has pinned one, not the origin the user happens to be visiting. A suite gets this wrong by default.
- For a Motoko backend:
mo:identity-attributes>= 0.4.0 (mops). It pulls inmo:core>= 2.5.0 and requiresmoc>= 1.6.0 forinclude. - For a Rust backend: the
identity-attributescrate andic-cdk>= 0.20.1.
How it works
When the backend needs more than the principal, II returns a signed attribute bundle alongside the delegation. The backend exposes two methods: _internet_identity_sign_in_start issues a nonce, _internet_identity_sign_in_finish verifies the bundle. The identity-attributes library adds both, in Motoko and in Rust, with the same checks, the same environment variables and the same Candid types, so one frontend works against either backend.
Keys
requestAttributes({ keys, nonce }) requires both; there is no default key set.
| Key | Meaning | Use for |
|---|---|---|
name |
Display name from the user’s II-linked account | Personalisation |
email |
Raw address from the II-linked account; II does not check it | Contact info, mailing lists |
verified_email |
Present only when II established the user controls the address (an OpenID provider marked it verified, or the user verified it with II) | Access gating; the only email to authorise on |
Request both email and verified_email for fallback: when verified, both carry the same value; otherwise only email arrives.
scopedKeys scopes keys to the account the user signs in with, so they are shared in the same step with no extra prompt:
| Call | Keys | Default keys |
|---|---|---|
scopedKeys({ openIdProvider: 'google' }) |
openid:https://accounts.google.com:<key> |
name, email, verified_email |
scopedKeys({ openIdProvider: 'apple' }) |
openid:https://appleid.apple.com:<key> |
same |
scopedKeys({ openIdProvider: 'microsoft' }) |
openid:https://login.microsoftonline.com/{tid}/v2.0:<key> ({tid} literal) |
same |
scopedKeys({ ssoDomain: 'acme.com' }) |
sso:acme.com:<key> |
name, email (no verified_email) |
Pass keys to narrow, e.g. scopedKeys({ openIdProvider: 'google', keys: ['name', 'verified_email'] }).
Frontend
import { AuthClient } from "@icp-sdk/auth/client";
import { AttributesIdentity } from "@icp-sdk/core/identity";
import { HttpAgent, Actor } from "@icp-sdk/core/agent";
import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env";
import { Principal } from "@icp-sdk/core/principal";
const II_PRINCIPAL = "rdmx6-jaaaa-aaaaa-aaadq-cai";
const rootKey = safeGetCanisterEnv()?.IC_ROOT_KEY;
async function signInWithAttributes(authClient, canisterId, idl) {
// Anonymous handle, used only to ask for the nonce.
const anonymousActor = Actor.createActor(idl, { agent: await HttpAgent.create({ rootKey }), canisterId });
// In parallel, one interaction for the user. `nonce` is a function the client
// calls when it needs the value, so the request is in flight while II opens.
const signInPromise = authClient.signIn();
const attributesPromise = authClient.requestAttributes({
keys: ["name", "verified_email"],
nonce: () => anonymousActor._internet_identity_sign_in_start(),
});
const identity = await signInPromise;
const attributes = await attributesPromise;
// The bundle travels as sender_info on each call made with this identity.
const verifiedAgent = await HttpAgent.create({
identity: new AttributesIdentity({
inner: identity,
attributes,
signer: { canisterId: Principal.fromText(II_PRINCIPAL) },
}),
rootKey,
});
const verifiedActor = Actor.createActor(idl, { agent: verifiedAgent, canisterId });
const result = await verifiedActor._internet_identity_sign_in_finish();
if ("err" in result) {
throw new Error(`Attribute verification failed: ${JSON.stringify(result.err)}`);
}
return identity;
}
For one-click sign-in, build the client with openIdProvider or ssoDomain, add scopedKeys to the @icp-sdk/auth/client import, and request scopedKeys({ openIdProvider: "google", keys: ["name", "verified_email"] }) or scopedKeys({ ssoDomain: "acme.com" }) instead; the rest is unchanged.
Every bundle carries three implicit fields the backend MUST verify: implicit:nonce (one it issued and has not consumed), implicit:origin (a trusted frontend origin), implicit:issued_at_timestamp_ns (fresh, typically within five minutes).
Backend
Configuration
Environment variables, the same for both languages:
canisters:
- name: backend
settings:
environment_variables:
# Required. A local II (`ii: true`) has the same principal.
trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai"
# Required, comma-separated. The origin identities are DERIVED for.
frontend_origins: "https://your-app.icp.net"
# Optional, comma-separated; omit to reject all sso:* keys.
trusted_sso_domains: "your-org.com"
A call carrying a bundle is rejected when trusted_attribute_signers is unset or does not list the bundle’s signer. Unset frontend_origins makes the finish method return FrontendOriginsNotConfigured.
What the callback receives
Your callback runs only for a bundle that passes every check below, and receives the caller with { name, email, sso } (each optional):
- OpenID and unscoped sources:
emailcomes fromverified_email(oropenid:<provider>:verified_email), which is why the frontend requestsverified_email. The unverifiedemailkey is never read. - SSO sources:
nameandemailcome fromsso:<domain>:name/sso:<domain>:email, accepted only for a domain intrusted_sso_domains, andssois that domain. Otherwisessois empty.
Checks and errors
The finish method returns Candid variant { ok; err : Error }, so the frontend checks "err" in result. It checks, in order:
| Check | Error |
|---|---|
| A bundle is attached | NoAttributes |
It decodes to an ICRC-3 Value::Map |
MalformedCandid |
frontend_origins is set |
FrontendOriginsNotConfigured |
implicit:origin is one of frontend_origins |
FrontendOriginMismatch { expected, got } |
implicit:issued_at_timestamp_ns is at most five minutes old |
Stale { ageNs } |
implicit:nonce was issued here and not consumed |
UnknownNonce |
| A required implicit field is present | MissingField |
Every sso:<domain>:* key’s domain is trusted |
UntrustedSsoSource { domain } |
Name and email come from sso: keys or from others, never both |
MixedSsoSources { ssoKeys, otherKeys } |
Each field comes from one key, and sso: keys from one domain |
AmbiguousAttribute { field, sources } |
UnknownNonce answers a nonce older than five minutes, one already spent, and, in Rust, one issued before an upgrade. Treat it as “retry from _internet_identity_sign_in_start with a fresh nonce”, not as a bug.
Motoko
[dependencies]
identity-attributes = "0.4.1"
core = "2.5.0"
[toolchain]
moc = "1.6.0"
include IdentityAttributes({ onVerified }) injects both methods:
import IdentityAttributes "mo:identity-attributes";
import Map "mo:core/Map";
import Principal "mo:core/Principal";
persistent actor {
type Profile = { name : ?Text; email : ?Text; sso : ?Text };
let profiles = Map.empty<Principal, Profile>();
include IdentityAttributes({
onVerified = func(caller, attrs) {
profiles.add(caller, attrs);
};
});
public query func getProfile(userId : Principal) : async ?Profile {
profiles.get(userId)
};
};
Rust
[dependencies]
candid = "0.10"
ic-cdk = "0.20.1"
identity-attributes = "0.1"
#[identity_attributes] marks the callback and adds both methods. The function cannot be async or generic:
use candid::Principal;
use ic_cdk::query;
use identity_attributes::{identity_attributes, IdentityAttributes};
use std::cell::RefCell;
use std::collections::BTreeMap;
thread_local! {
// On the heap to keep the example short: an upgrade clears it.
static PROFILES: RefCell<BTreeMap<Principal, IdentityAttributes>> =
const { RefCell::new(BTreeMap::new()) };
}
#[identity_attributes]
fn on_verified(caller: Principal, attributes: IdentityAttributes) {
PROFILES.with_borrow_mut(|profiles| profiles.insert(caller, attributes));
}
#[query]
fn get_profile(user_id: Principal) -> Option<IdentityAttributes> {
PROFILES.with_borrow(|profiles| profiles.get(&user_id).cloned())
}
ic_cdk::export_candid!();
identity_attributes::sign_in_start() and identity_attributes::sign_in_finish(on_verified) are the functions behind the two methods, for a canister that defines them itself.
Mistakes That Break Your Build
- Generating the attribute nonce on the frontend. The nonce passed to
requestAttributesMUST come from the backend (_internet_identity_sign_in_start), or the canister cannot check that the bundle’simplicit:nonceis one it issued, and replay protection is gone. - Reading attribute data without verifying the signer. The IC verifies the signature, not who signed: any canister can produce a valid bundle. The trusted signer is
rdmx6-jaaaa-aaaaa-aaadq-cai. Use theidentity-attributeslibrary withtrusted_attribute_signersandfrontend_originsconfigured. Hand-written Rust must checkmsg_caller_info_signer()before readingmsg_caller_info_data(), or an attacker canister can forgeemail = "admin@you.com". - Substituting
{tid}in the Microsoft scoped-key prefix. Themicrosoftprovider URL is the literalhttps://login.microsoftonline.com/{tid}/v2.0; keys look likeopenid:https://login.microsoftonline.com/{tid}/v2.0:emailexactly. Filling in a tenant ID misses every lookup. - Treating
emailas verified.emailis the raw address from the user’s II-linked account; II does not check it, so treat it as user input.verified_emailis present only when II established that the user controls the address: an OpenID provider marked it verified, or the user verified it with II. Gate access onverified_emailonly. There is noverified_emailundersso:: the organization’s own provider assertssso:<domain>:email, so trust it only for domains intrusted_sso_domains. What an organization can and cannot assert about its own staff is theorganization-sign-inskill.