Authenticator Shared Object Inputs
Account Abstraction is currently a release candidate and, therefore, only available on Testnet and Devnet.
If you test it out, please share your feedback with us in the IOTA Builders Discord.
This how-to explains a critical security vulnerability that can occur when authenticator functions accept shared objects as input parameters, potentially allowing attackers to bypass authentication checks.
The Vulnerability
When an authenticator function accepts a shared object as an input parameter, an attacker may be able to swap that shared object with a different one that has the same type but different data. This can allow the attacker to bypass authentication checks that depend on the data within that shared object.
Attack Scenario
Consider an account with an authenticator that checks if the sender is blacklisted using a shared Blacklist object:
- The legitimate user's account address is added to a blacklist
- The user creates a transaction that should be blocked by the blacklist check
- An attacker intercepts the transaction
- The attacker creates a new empty blacklist shared object
- The attacker swaps the blacklist object reference in the transaction to point to the empty one
- The transaction passes authentication because the empty blacklist doesn't contain the account address
Vulnerable Implementation
Here's an example of a vulnerable authenticator that uses a shared object input:
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
module authenticator_shared_object_swap::account;
use authenticator_shared_object_swap::blacklist::Blacklist;
use iota::account;
use iota::authenticator_function::AuthenticatorFunctionRefV1;
use iota::dynamic_field;
use iota::ed25519;
use iota::hex::decode;
#[error(code = 0)]
const EEd25519VerificationFailed: vector<u8> = b"Ed25519 authenticator verification failed.";
#[error(code = 1)]
const EAccountIsBlacklisted: vector<u8> = b"Account is blacklisted.";
/// A dynamic field key for storing the account owner public key.
public struct OwnerPublicKey has copy, drop, store {}
/// This struct represents an account protected by an Ed25519 signature.
public struct Account has key {
id: UID,
}
/// Creates a new `Account` instance as a shared object with the given public key and authenticator.
public fun create(
public_key: vector<u8>,
authenticator: AuthenticatorFunctionRefV1<Account>,
ctx: &mut TxContext,
) {
let mut account = Account { id: object::new(ctx) };
dynamic_field::add(&mut account.id, owner_public_key(), public_key);
account::create_account_v1(account, authenticator);
}
/// Authenticates access for the `Account`.
/// Verifies the provided Ed25519 signature against the stored public key.
/// Additionally, checks if the sender's account is blacklisted using the provided `blacklist` shared object.
///
/// IMPORTANT: This authenticator uses a shared object for blacklist checking, which may introduce
/// potential vulnerabilities if the shared object is not properly managed.
#[authenticator]
public fun authenticate(
account: &Account,
signature: vector<u8>,
blacklist: &Blacklist,
_: &AuthContext,
ctx: &TxContext,
) {
assert!(!blacklist.is_blacklisted(ctx.sender()), EAccountIsBlacklisted);
assert!(
ed25519::ed25519_verify(&decode(signature), account.public_key(), ctx.digest()),
EEd25519VerificationFailed,
);
}
/// Helper function to get the dynamic field key for owner public key.
fun owner_public_key(): OwnerPublicKey {
OwnerPublicKey {}
}
/// Helper function to borrow the owner public key from the account.
fun public_key(self: &Account): &vector<u8> {
dynamic_field::borrow(&self.id, owner_public_key())
}
The blacklist implementation:
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
module authenticator_shared_object_swap::blacklist;
#[error(code = 0)]
const EAccountIsAlreadyBlacklisted: vector<u8> = b"Account is already blacklisted.";
/// A shared object that maintains a blacklist of accounts.
///
/// IMPORTANT: Using a shared object for blacklist management may introduce potential vulnerabilities
/// if the shared object is not properly managed. Ensure that only trusted parties can create/modify the blacklist.
public struct Blacklist has key {
id: UID,
accounts: vector<address>,
}
/// Creates a new `Blacklist` shared object.
public fun create(ctx: &mut TxContext) {
let blacklist = Blacklist { id: object::new(ctx), accounts: vector::empty() };
transfer::share_object(blacklist);
}
/// Adds an account address to the blacklist.
public fun add(blacklist: &mut Blacklist, account: address) {
assert!(!blacklist.accounts.contains(&account), EAccountIsAlreadyBlacklisted);
blacklist.accounts.push_back(account);
}
/// Returns `true` if the given account address is blacklisted.
public fun is_blacklisted(blacklist: &Blacklist, account: address): bool {
blacklist.accounts.contains(&account)
}
Problem: The authenticator accepts any Blacklist shared object. An attacker can create their own empty blacklist and swap it in.
Exploitation Example
An attacker can exploit this vulnerability by swapping the shared object:
println!("Recipient C address: {recipient_c}");
let transaction = create_test_transaction(
&iota_client,
&mut keystore,
publisher,
recipient_c,
&account_ref,
&blacklist_ref,
)
.await?;
// Create a new empty blacklist shared object instance
let empty_blacklist_ref =
create_blacklist(&iota_client, &mut keystore, publisher, &package_id).await?;
// Swap the blacklist shared object in the transaction with the empty one
let hacked_transaction = swap_blacklist_in_transaction(transaction, &empty_blacklist_ref);
// Execute the hacked transaction.
// The transaction is expected to be succeeded as the blacklist shared object
// has been swapped with an empty one.
let _ = execute_transaction(&iota_client, hacked_transaction).await?;
// Get a transferred coin from the recipient address C to verify the
// transaction succeeded
let transferred_coin = get_coin(&iota_client, recipient_c).await?;
println!("Recipient C coin: {transferred_coin:?}");
The swap function replaces the shared object reference in the transaction:
/// Swaps the blacklist shared object in the transaction with a new one.
pub fn swap_blacklist_in_transaction(
mut transaction: Transaction,
new_blacklist_ref: &ObjectRef,
) -> Transaction {
let new_blacklist_ref_call_arg = CallArg::Shared(SharedObjectRef {
object_id: new_blacklist_ref.object_id,
initial_shared_version: new_blacklist_ref.version,
mutable: false,
});
let new_sig = match &transaction.inner_mut().tx_signatures[0] {
GenericSignature::MoveAuthenticator(move_authenticator) => {
let signature_call_arg = move_authenticator.call_args()[0].clone();
let account_call_arg = move_authenticator.object_to_authenticate().clone();
GenericSignature::MoveAuthenticator(MoveAuthenticator::new_v1(
vec![signature_call_arg, new_blacklist_ref_call_arg],
vec![],
account_call_arg,
))
}
_ => panic!("Expected MoveAuthenticator signature"),
};
transaction.inner_mut().tx_signatures[0] = new_sig;
transaction
}
Secure Implementations
There are several approaches to mitigate this vulnerability:
Option 1: Verify Shared Object Identity
If you need to use a shared object, cryptographically bind it to the account by storing its ID:
/// A dynamic field key used for storing the "authorized blacklist" for an account.
public struct AuthorizedBlacklist has copy, drop, store {}
#[authenticator]
public fun authenticate(
account: &Account,
blacklist: &Blacklist,
_: &AuthContext,
ctx: &TxContext,
) {
// ✅ SECURE: Verify this is the authorized blacklist
let authorized_blacklist_id = *dynamic_field::borrow(&account.id, AuthorizedBlacklist {});
assert!(object::id(blacklist) == authorized_blacklist_id, EUnauthorizedBlacklist);
// Now safe to use the blacklist
assert!(!blacklist.is_blacklisted(ctx.sender()), EAccountIsBlacklisted);
...
}
Option 2: Make sure that it is possible to create only one Shared Object instance
There is no need to check the ID of a shared object if only one instance of that type can be created.
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
module authenticator_shared_object_swap::blacklist;
/// OTW
public struct BLACKLIST has drop {}
#[error(code = 0)]
const EAccountIsAlreadyBlacklisted: vector<u8> = b"Account is already blacklisted.";
/// A shared object that maintains a blacklist of accounts.
public struct Blacklist has key {
id: UID,
accounts: vector<address>,
}
/// Module initializer
fun init(_: BLACKLIST, ctx: &mut TxContext) {
// ✅ SECURE: Only one `Blacklist` instance can be created
let blacklist = Blacklist { id: object::new(ctx), accounts: vector::empty() };
transfer::share_object(blacklist);
}
/// Adds an account address to the blacklist.
public fun add(blacklist: &mut Blacklist, account: address) {
assert!(!blacklist.accounts.contains(&account), EAccountIsAlreadyBlacklisted);
blacklist.accounts.push_back(account);
}
/// Returns `true` if the given account address is blacklisted.
public fun is_blacklisted(blacklist: &Blacklist, account: address): bool {
blacklist.accounts.contains(&account)
}
However, please keep in mind that creating several instances may become possible with a package update.
Option 3: Avoid shared objects in authenticators
Whenever possible, store critical authentication data in the account itself using dynamic fields instead of using a shared object.
/// A dynamic field key used for storing the information whether an account is blacklisted.
public struct IsBlacklisted has copy, drop, store {}
#[authenticator]
public fun authenticate(account: &Account, _: &AuthContext, _: &TxContext) {
// ✅ SECURE: Verify if the account is blacklisted based on the dynamic field
let is_blacklisted = *dynamic_field::borrow(&account.id, IsBlacklisted {});
assert!(!is_blacklisted, EAccountIsBlacklisted);
...
}
Key Takeaways
✅ Verify object identity: If shared objects are necessary, always verify the object ID matches an expected value stored in the account
✅ Make sure that a shared object managed correctly: There is no need to check the ID of a shared object if only one instance of that type can be created
✅ Avoid shared objects in authenticators: Whenever possible, store authentication data directly in the account using dynamic fields
✅ Document security assumptions: Clearly document what objects are trusted and how they're verified
✅ Test object swapping: Create tests that attempt to swap shared objects to verify your implementation is secure
❌ Don't assume type safety is enough: Just because an object has the right type doesn't mean it's the right object
❌ Don't trust arbitrary shared objects: Any shared object can be passed by an attacker
❌ Don't skip validation: Always validate that shared objects are the ones you expect