Summary
AttributesIdentity.transformRequest unconditionally injects sender_info into the body of every outgoing HttpAgentRequest. That includes the read_state polls the agent fires after submitting an update call. The IC's read_state hash does not include sender_info in its representation-independent hash, so the polled request's basic signature ends up over hash(body+sender_info) while the IC verifies against hash(body) — every poll fails with:
Status: 400
Body: Invalid signature: Invalid basic signature: Ed25519 signature could not be verified: …
Net effect: any update call made through AttributesIdentity succeeds at /api/v4/.../call but then the agent can never poll the response. To the caller it looks like every actor.method() rejects.
This affects every consumer of AttributesIdentity that calls a non-query canister method, on @icp-sdk/core@^5.x (verified against 5.3.1).
Reproduction
import { HttpAgent, Actor } from '@icp-sdk/core/agent';
import { AuthClient } from '@icp-sdk/auth/client';
import { AttributesIdentity, Ed25519KeyIdentity } from '@icp-sdk/core/identity';
import { Principal } from '@icp-sdk/core/principal';
const session = Ed25519KeyIdentity.generate();
const auth = new AuthClient({
identity: session,
identityProvider: 'https://id.ai/authorize',
});
const [inner, attrs] = await Promise.all([
auth.signIn(),
pendingNonce.then((nonce) =>
auth.requestAttributes({ keys: ['sso:dfinity.org:email'], nonce }),
),
]);
const identity = new AttributesIdentity({
inner,
attributes: attrs,
signer: { canisterId: Principal.fromText('rdmx6-jaaaa-aaaaa-aaadq-cai') }, // II prod
});
const agent = await HttpAgent.create({ host: 'https://icp0.io', identity });
const actor = Actor.createActor(idl, { agent, canisterId: someCanister });
await actor.someUpdate(); // 400 Invalid basic signature on the read_state poll
The bare-delegation flow (skip AttributesIdentity, use inner directly) works fine.
Wire-level evidence
After instrumenting globalThis.fetch and dumping the CBOR-encoded request bodies for one actor.register() call I see exactly four request bodies posted:
- 1 ×
/api/v4/<canister>/call (4595 bytes) — both `sender_info` and a valid sender_sig
- 3 ×
/api/v2/<canister>/read_state (4580 bytes each) — also has `sender_info` in content, plus a different sender_sig
The IC's 400 error reports the read_state's sender_sig (matches the sig field bytes in the read_state body, NOT the /call). I.e. the call itself was accepted; only the poll fails.
Decoding the read_state body's content shows:
content: a5
6c 'request_type' : 6a 'read_state'
65 'paths' : 81 [ 82 'request_status' <hash> ]
66 'sender' : 581d <29-byte principal>
6e 'ingress_expiry' : 1b <u64>
6b 'sender_info' : a3 { signer, info, sig } ← injected by AttributesIdentity
Root cause
representation_independent_hash_call_or_query includes sender_info in the hashed map.
representation_independent_hash_read_state hashes only { request_type, ingress_expiry, paths, sender, nonce? } — sender_info is not part of the read_state schema and is not hashed.
But agent-js's AttributesIdentity.transformRequest:
transformRequest(request: HttpAgentRequest): Promise<unknown> {
return this.#inner.transformRequest({
...request,
body: {
...request.body,
sender_info: { signer, info, sig }, // unconditional
},
} as HttpAgentRequest);
}
…doesn't look at request.body.request_type, so it pollutes the read_state body with sender_info. The DelegationIdentity it wraps then computes requestIdOf(body) — which now includes the injected field — and signs that hash. The IC re-derives its own hash without the field, and the signatures disagree.
Suggested fix
Gate the injection on the request type:
transformRequest(request: HttpAgentRequest): Promise<unknown> {
const body = request.body as { request_type?: string } | undefined;
if (body?.request_type !== 'call' && body?.request_type !== 'query') {
// read_state, etc. — IC doesn't hash sender_info on these endpoints
return this.#inner.transformRequest(request);
}
return this.#inner.transformRequest({
...request,
body: {
...request.body,
sender_info: {
signer: this.#signer.canisterId.toUint8Array(),
info: this.#attributes.data,
sig: this.#attributes.signature,
},
},
} as HttpAgentRequest);
}
(Also worth adding a unit test that constructs a fake HttpAgentRequest with request_type: 'read_state' and asserts the transformed body has no sender_info.)
Workaround
Subclass / replace AttributesIdentity locally with a version that does the request-type gate. Verified to fix the bug end-to-end against prod II + a Motoko canister using `mo:identity-attributes`'s `#Authorization` policy. Reference implementation:
https://github.com/aterga/identity-attributes/blob/feat/use-icp-sdk-auth/demos/bagel/frontend/src/main.ts (search for CallOnlyAttributesIdentity)
Environment
@icp-sdk/core@5.3.1
@icp-sdk/auth@6.2.2 (also reproduces with 6.2.1)
- IC replica revision `52cf4d105ed7ae882e45d09d364c961d2fb442d3` (subnet
io67a-…-gqe, deployed 2026-04-26)
- Browsers: Chrome 122 on macOS
Summary
AttributesIdentity.transformRequestunconditionally injectssender_infointo the body of every outgoingHttpAgentRequest. That includes theread_statepolls the agent fires after submitting an update call. The IC'sread_statehash does not includesender_infoin its representation-independent hash, so the polled request's basic signature ends up overhash(body+sender_info)while the IC verifies againsthash(body)— every poll fails with:Net effect: any update call made through
AttributesIdentitysucceeds at/api/v4/.../callbut then the agent can never poll the response. To the caller it looks like everyactor.method()rejects.This affects every consumer of
AttributesIdentitythat calls a non-query canister method, on@icp-sdk/core@^5.x(verified against5.3.1).Reproduction
The bare-delegation flow (skip
AttributesIdentity, useinnerdirectly) works fine.Wire-level evidence
After instrumenting
globalThis.fetchand dumping the CBOR-encoded request bodies for oneactor.register()call I see exactly four request bodies posted:/api/v4/<canister>/call(4595 bytes) — both `sender_info` and a valid sender_sig/api/v2/<canister>/read_state(4580 bytes each) — also has `sender_info` incontent, plus a different sender_sigThe IC's 400 error reports the read_state's sender_sig (matches the sig field bytes in the read_state body, NOT the /call). I.e. the call itself was accepted; only the poll fails.
Decoding the read_state body's
contentshows:Root cause
representation_independent_hash_call_or_queryincludessender_infoin the hashed map.representation_independent_hash_read_statehashes only{ request_type, ingress_expiry, paths, sender, nonce? }—sender_infois not part of the read_state schema and is not hashed.But agent-js's
AttributesIdentity.transformRequest:…doesn't look at
request.body.request_type, so it pollutes the read_state body withsender_info. The DelegationIdentity it wraps then computesrequestIdOf(body)— which now includes the injected field — and signs that hash. The IC re-derives its own hash without the field, and the signatures disagree.Suggested fix
Gate the injection on the request type:
(Also worth adding a unit test that constructs a fake
HttpAgentRequestwithrequest_type: 'read_state'and asserts the transformed body has nosender_info.)Workaround
Subclass / replace
AttributesIdentitylocally with a version that does the request-type gate. Verified to fix the bug end-to-end against prod II + a Motoko canister using `mo:identity-attributes`'s `#Authorization` policy. Reference implementation:https://github.com/aterga/identity-attributes/blob/feat/use-icp-sdk-auth/demos/bagel/frontend/src/main.ts (search for
CallOnlyAttributesIdentity)Environment
@icp-sdk/core@5.3.1@icp-sdk/auth@6.2.2(also reproduces with 6.2.1)io67a-…-gqe, deployed 2026-04-26)