Skip to main content

Transaction Data Cryptographic Protection in Authenticators

Account Abstraction RC

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 the importance of cryptographic protection for the transaction data when building authenticator functions and demonstrates a critical vulnerability that can occur when it's missing.

The Vulnerability

When implementing custom authenticator functions for abstract accounts, it's critical to include cryptographic verification of the transaction data. Without proper cryptographic protection, an attacker can tamper with transaction parameters, potentially redirecting funds or executing unauthorized actions.

Attack Scenario

Consider a time-locked account that only allows transactions after a specific unlock time. If the authenticator only checks the time condition without cryptographically binding the authentication to the specific transaction data, an attacker can:

  1. Create a legitimate transaction from the account (e.g., transferring funds to address A)
  2. Sign the transaction with the account's authenticator
  3. Modify the transaction data before the transaction is processed by a validator (e.g., change recipient from address A to attacker's address B)
  4. Submit the modified transaction, which will be accepted

Vulnerable Implementation

Here's an example of a vulnerable authenticator that lacks transaction data cryptographic protection:

// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

module authenticator_tx_data_cryptographic_protection::account;

use iota::account;
use iota::authenticator_function::AuthenticatorFunctionRefV1;
use iota::dynamic_field;

#[error(code = 0)]
const EAccountStillLocked: vector<u8> = b"The account is still locked.";

/// A dynamic field key used for storing the "unlock time" for an account.
public struct UnlockTime has copy, drop, store {}

/// This struct represents a time-locked account.
public struct Account has key {
id: UID,
}

/// Creates a new `Account` instance as a shared object with the given unlock time and authenticator.
public fun create(
unlock_time: u64,
authenticator: AuthenticatorFunctionRefV1<Account>,
ctx: &mut TxContext,
) {
let mut account = Account { id: object::new(ctx) };

dynamic_field::add(&mut account.id, unlock_time_key(), unlock_time);

account::create_account_v1(account, authenticator);
}

/// Authenticates access for the `Account`.
/// Checks if current clock time has passed the unlock time stored in the account.
///
/// IMPORTANT: This authenticator misses cryptographic protection for the transaction being authenticated,
/// that could allow an attacker to execute unauthorized actions or manipulate the transaction data.
#[authenticator]
public fun vulnerable_authenticate(account: &Account, _: &AuthContext, ctx: &TxContext) {
assert!(ctx.epoch_timestamp_ms() >= account.unlock_time(), EAccountStillLocked);
}

/// Helper function to get the dynamic field key for unlock time.
fun unlock_time_key(): UnlockTime {
UnlockTime {}
}

/// Helper function to get the unlock time from the account.
fun unlock_time(self: &Account): u64 {
*dynamic_field::borrow(&self.id, unlock_time_key())
}

Problem: The authenticator only checks the time condition. It doesn't verify that the transaction data hasn't been tampered with.

Exploitation Example

An attacker can exploit this vulnerability:

    // Create one more test transaction
let transaction = create_test_transaction(&iota_client, recipient, &account_ref).await?;

// Swap the recipient in the transaction to an attacker-controlled address
let attacker = IotaAddress::random();

println!("Attacker address: {attacker}");

let hacked_transaction = swap_recipient_in_transaction(transaction, attacker);

// Execute the hacked transaction.
// Due to the missing cryptographic protection in the authenticator
// implementation, the transaction will be accepted and executed successfully.
let _ = execute_transaction(&iota_client, hacked_transaction).await?;

The swap_recipient_in_transaction function modifies the programmable transaction block inputs:

/// Swaps the recipient in the transaction to an attacker-controlled address.
pub fn swap_recipient_in_transaction(
mut transaction: Transaction,
attacker: IotaAddress,
) -> Transaction {
match &mut transaction.inner_mut().intent_message.value {
TransactionData::V1(data) => match &mut data.kind {
TransactionKind::Programmable(ptb) => {
ptb.inputs[0] = CallArg::Pure(bcs::to_bytes(&attacker).unwrap());
}
_ => panic!("Expected a programmable transaction"),
},
_ => panic!("Expected a V1 transaction"),
}

transaction
}

Secure Implementation

To prevent this attack, always include cryptographic verification in your authenticator. Here are recommended approaches:

Option 1: Signature Verification

Use standard, for example, Ed25519 signature verification that cryptographically binds the authentication to the transaction digest:

#[authenticator]
public fun authenticate(
account: &Account,
signature: vector<u8>,
_: &AuthContext,
ctx: &TxContext,
) {
// ✅ SECURE: Verify signature against transaction digest
let public_key = dynamic_field::borrow(&account.id, OwnerPublicKey {});
assert!(
ed25519::ed25519_verify(&signature, public_key, ctx.digest()),
EEd25519VerificationFailed
);

...
}

Key Takeaways

Always verify transaction data: Every authenticator must cryptographically bind the authentication to the specific transaction being executed

Use transaction digest: The ctx.digest() function provides a cryptographic hash of the entire transaction that should be used in signature verification

Test for tampering: When developing authenticators, create tests that attempt to modify transaction data after authentication to verify your implementation is secure

Never rely on application logic alone: Time checks, balance checks, or other business logic must be combined with cryptographic verification