---
id: TIP-1165
title: Pre-Authorized Access Key Signatures
description: A signature format and offchain verification rule for a message signed by an access key that the account has authorized but not yet registered onchain.
authors: Jake Moxey (@jxom)
status: Draft
related: TIP-0001, TIP-1011, TIP-1020, TIP-1049, TIP-1053, TIP-1137
protocolVersion: n/a
---

# TIP-1165: Pre-Authorized Access Key Signatures

## Abstract

This TIP adds a **pre-authorized signature**: an access key's signature bundled with the root key's signed approval of that access key. It ends in a 32-byte magic suffix. Today an access key's signature verifies only after a transaction registers the key onchain. With this format, a verifier also accepts the signature when the bundled approval would still register the key.

The rule is an offchain convention. It changes no consensus rule. Onchain verification does not accept the format.

## Motivation

On Tempo, an account's **root key** is the key whose address is the account. The root key approves an **access key** by signing a **key authorization** ([TIP-0001](./tip-0001.md)). An access key has its own expiry, spending limits, and call scopes ([TIP-1011](./tip-1011.md)). The AccountKeychain precompile stores registered access keys. A message signed by an access key verifies through `verifyKeychain` on the SignatureVerifier precompile ([TIP-1020](./tip-1020.md)). That function reads the AccountKeychain.

A wallet commonly gives an app a signed key authorization without sending a transaction. The access key registers only when the first transaction it signs carries that authorization. Until then the AccountKeychain has no record of the access key, so `verifyKeychain` returns false for every message the access key signs.

Apps hit this gap whenever they need a signature before a transaction:

- **Payment proofs.** A server asks a client to prove control of an account before it serves a paid resource. The client holds only an access key. The proof fails until the client pays gas for an unrelated transaction that registers the access key.
- **Sign-in.** A server verifies signed challenges from a session's access key. [TIP-1053](./tip-1053.md) lets the key authorization carry one challenge digest, its witness. Later challenges in the same session have no path.
- **Offchain orders and intents.** A verifier checks a signed message that settles later, in the same transaction that registers the access key.

In each case the root key has already approved the access key, with a signature anyone can check. The verifier lacks only a standard way to receive that approval and a standard rule for when to accept it.

Ethereum solved the same problem for undeployed contract accounts with ERC-6492, and for undelegated EOAs with ERC-8010. Both append data and a magic suffix to the signature. This TIP does the same for an unregistered access key.

Alternatives considered:

- **A new signature type byte**, such as `0x06`. It puts an offchain-only format in the ID space of transaction signatures, and nodes must then reject it explicitly.
- **A new SignatureVerifier function.** It is the right home for onchain verification, but it needs a hardfork, and offchain verifiers gain nothing from it. A later TIP can add it and reuse this encoding.
- **Register first.** The app sends a transaction before the first signature. It costs gas and a confirmation for a step the user does not see as a transaction.

## Assumptions

- The verifier reads chain state through a node it trusts, at a block of its choice. A stale or dishonest node can hide a revocation or a burned witness.
- A result holds only for the block it was computed at. It does not promise that the access key registers later, just as a `verifyKeychain` result does not promise that the access key stays active.
- The signed message carries its own replay protection and domain separation, as with any other signature.
- The checks in [Verification](#verification) mirror the checks a transaction's key authorization must pass. A hardfork that changes those checks changes this rule with it. An implementation that falls behind accepts or refuses signatures that the chain does not.

---

# Specification

## Overview

### Terms

| Term | Meaning |
|---|---|
| Account | The address the signature claims to sign for. |
| Root key | The key whose address is the account. It signs key authorizations, unless a configuration commitment retired it. |
| Access key | The key that the key authorization approves, identified by its address `key_id`. |
| Key authorization | `key_authorization`, the root key's approval of an access key, as in the `key_authorization` field of a Tempo Transaction ([TIP-0001](./tip-0001.md)). Its fields are listed in [Encoding](#encoding). |
| Signed key authorization | The key authorization with the root key's signature over it. |
| Witness | An optional 32-byte value in a key authorization ([TIP-1053](./tip-1053.md)). After the account burns it with `burnKeyAuthorizationWitness`, no key authorization that carries it can register. |
| Configurable account | An account whose signing rules are fixed by a nonzero configuration commitment instead of by its own key. Setting the commitment retires the root key: the key no longer signs for the account and cannot be registered as an access key. `verifyConfigurableAccount` checks the account's signatures. |
| Keychain signature | A signature by an access key for an account: `0x04 ‖ account ‖ inner_signature`, the V2 keychain format of [TIP-0001](./tip-0001.md#keychain-variable-length), where it names the account `user_address`. |
| AccountKeychain | The precompile that stores each account's access keys. `authorizeKey` registers an access key, `getKey` reads one, and `isAdminKey(account, keyId)` returns true when `keyId` is the account's own unretired root key or one of its active admin access keys. |
| SignatureVerifier | The precompile with `verify`, `recover`, `verifyKeychain`, and `verifyConfigurableAccount` ([TIP-1020](./tip-1020.md)). |
| Registration | The step that writes the access key into the AccountKeychain. A transaction that carries the signed key authorization performs it through `authorizeKey`. |
| Reference block | The block whose state and timestamp the verifier uses. The default is the latest block. |

### Validity

A pre-authorized signature is valid for an `(account, hash)` pair if, at the reference block, one of these holds:

1. **Registered.** The keychain signature passes `verifyKeychain(account, hash, keychain_signature)`.
2. **Registrable.** The keychain signature is a correct signature by the access key. A transaction from the account that carries the signed key authorization would register the access key. Steps 4 to 6 of [Verification](#verification) define this precisely.

Only the root key, or a configurable account's own signature, can approve an access key. Holding the access key alone, or the signed key authorization alone, is not enough to produce a valid signature.

The format is for verifiers of signed messages. It is never a transaction signature, and a node must not accept it as one.

### Scope limits

- An access key that is itself a configurable account (inner signature type `0x05`) is out of scope. A later TIP can extend step 4.
- A V1 keychain signature (`0x03`) is never valid in this format.
- A key authorization that creates an admin access key (`is_admin` set, [TIP-1049](./tip-1049.md)) or that an admin access key signed is never valid, including on networks before [TIP-1137](./tip-1137.md) activates.
- Onchain verification is out of scope. A contract that calls `verifyKeychain` or `isValidSignature` with a pre-authorized signature gets a failure.

## Mechanics

### Encoding

```
pre_authorized_signature = rlp([keychain_signature, signed_key_authorization]) ‖ magic

keychain_signature        = 0x04 ‖ account ‖ inner_signature
signed_key_authorization  = rlp([key_authorization, signature])
key_authorization         = rlp([chain_id, key_type, key_id, expiry?, limits?, allowed_calls?, witness?, is_admin?, account?])
magic                     = 0x1165116511651165116511651165116511651165116511651165116511651165
```

- `magic` is the TIP number, `0x1165`, repeated 16 times.
- `keychain_signature` is an RLP byte string. `inner_signature` is the access key's signature over `keccak256(0x04 ‖ hash ‖ account)`, as for any V2 keychain signature.
- `signed_key_authorization` is a nested RLP list, not a byte string, and `key_authorization` is a list nested inside it. Its bytes equal the `key_authorization` field of the transaction that would register the access key.
- `signature` is the root key's signature over `keccak256(rlp(key_authorization))`: a primitive signature, or a configurable account signature.
- The outer list has exactly two items. The RLP must be canonical, with no bytes between the list and `magic`.

The fields of `key_authorization`, as in the transaction format:

| Field | Meaning |
|---|---|
| `chain_id` | The chain the authorization is for. |
| `key_type` | The access key's signature type: secp256k1, P256, or WebAuthn. |
| `key_id` | The access key's address. |
| `expiry` | Unix timestamp at which the access key expires. Absent means it never expires. |
| `limits` | Per-token spending limits. Absent means no limits. |
| `allowed_calls` | Call scopes ([TIP-1011](./tip-1011.md)): targets, selectors per target, and recipients per selector. Absent means any call. |
| `witness` | The witness ([TIP-1053](./tip-1053.md)). |
| `is_admin` | Set to create an admin access key ([TIP-1049](./tip-1049.md)). |
| `account` | The account the authorization is bound to. |

A field marked `?` is optional. Optional fields are trailing. Absent fields at the end are omitted. An absent field before a present one is the empty string `0x80`.

The signer wraps the same keychain signature that it would produce without this TIP. Once the access key is registered, the signer should return the plain keychain signature.

### Example

A secp256k1 access key `0x7298…012e` signs for the account `0xf39f…2266` on chain `4217`. The authorization has an expiry, one spending limit, and a witness.

```
hash                      0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8

keychain_signature        0x04f39fd6e51aad88f6f4ce6ab8827279cfffb922663dc3ccd3e78382806b81f811192a9a
                          fa9f38335c19a3897a2676008ab611d8a61c73da6988f81a2cc37c5c712f67280aeb732e45
                          688c9347458092b772306c6c1c

signed_key_authorization  0xf8a0f85b821079809472989d24e6e1f0bbe29a5f304d0db21a318c012e84713fb300dad994
                          20c00000000000000000000000000000000000018398968080a0e55f870744098e6baf2f15
                          e0faa6f8f9de283280f26f416dee8dcf9189a553c5b841c026ad99f18042ef3ac0d7a88ffb
                          cd910eb4ccaf435b1acf684f6eb63425909642b07095e6f94edbf489ac1909caa41b0e3eee
                          6971862939e8403c1ab4e617ca1b

pre_authorized_signature  0xf8fa
                            b856 <keychain_signature>
                            <signed_key_authorization>
                          1165116511651165116511651165116511651165116511651165116511651165
```

`hash` is `keccak256("hello")`. In `pre_authorized_signature`, `f8fa` is the header of the outer RLP list and `b856` is the header of the 86-byte `keychain_signature` string. Line breaks inside a value are for display. The root key is `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80` and the access key is `0x59c6995e998f97a5a0044966f0945389dc9d86dae88c7a8412f4603b6b78690d`. Both are public test keys.

### Verification

The inputs are `account`, `hash`, `signature`, and a reference block. The result is a boolean. Every failure, including a decode failure, returns false. Steps 3 to 6 read state at the reference block, and a verifier can batch the reads in one call.

1. **Detect.** If the last 32 bytes of `signature` do not equal `magic`, the signature is not pre-authorized. Verify it with the existing rules, such as `verifyKeychain` for a keychain signature.
2. **Decode.** Remove `magic`. RLP-decode the rest into `keychain_signature` and `signed_key_authorization` under the rules in [Encoding](#encoding).
3. **Registered path.** Call `verifyKeychain(account, hash, keychain_signature)`. If it returns true, return true without reading the signed key authorization. It returns false for an access key that is unregistered, revoked, or expired, and verification continues.
4. **Access key signature.** Check the keychain signature:
   - Its type byte is `0x04`, and its account field equals `account`.
   - `inner_signature` is a primitive signature: secp256k1, P256, or WebAuthn.
   - The key recovered from `inner_signature` over `keccak256(0x04 ‖ hash ‖ account)` equals `key_id`.
   - The type of `inner_signature` equals `key_type`.
5. **Key authorization.** Check the signed key authorization as a transaction from `account` that carries it would be checked:
   - `chain_id` equals the chain ID.
   - `key_id` is not the zero address, and `isAdminKey(key_id, key_id)` returns true. The second call asks whether `key_id` is the root key of its own address. It returns false only when a configuration commitment retired that root key, and `authorizeKey` refuses a retired root key as an access key.
   - `account`, if present, equals `account`.
   - `is_admin` is not set.
   - `expiry`, if present, is greater than the reference block's timestamp.
   - `signature` is a valid root signature for `account` over `digest = keccak256(rlp(key_authorization))`:
     - For a primitive signature, `verify(account, digest, signature)` returns true, and `isAdminKey(account, account)` returns true. The second call returns false when a configuration commitment retired the account's root key.
     - For a configurable account signature, `verifyConfigurableAccount(account, digest, signature)` returns true.
   - `limits`, if present, pass the checks of `authorizeKey`:
     - No token appears twice.
     - Every limit is at most `2^128 - 1`.
     - The list has at most 256 entries, the transaction pool's cap.
   - `allowed_calls`, if present, pass the checks of `authorizeKey`:
     - Every target is nonzero, and no target appears twice.
     - Within a target, no selector appears twice.
     - A selector rule that lists recipients has a TIP-20 target and the `transfer`, `transferWithMemo`, or `approve` selector, and its recipients are nonzero and distinct.
     - There are at most 64 targets, at most 64 selector rules per target, and at most 64 recipients per rule, the transaction pool's caps.
   - The cheapest transaction from the account that carries the authorization fits the transaction gas limit cap of the reference block's hardfork (30,000,000 gas from T1A). Its intrinsic gas is computed as the transaction path computes it at the reference block. It includes the base transaction gas, the key authorization's gas with its call-scope surcharge, and the verification gas of the transaction's own signature. It also includes the nonce gas for the account's current nonce and any configurable account gas. The pool caps above do not bound this: 64 targets with 64 selector rules each, one with 64 recipients per target, cost 49,605,000 gas in call-scope surcharge alone.
6. **Registration state.** Check the AccountKeychain:
   - `getKey(account, key_id)` returns `keyId == address(0)` and `isRevoked == false`: the access key was never registered and never revoked.
   - If `witness` is present, `isKeyAuthorizationWitnessBurned(account, witness)` returns false.
7. Return true.

After step 7 a verifier may apply its own policy. On the registrable path, the decoded key authorization gives the access key's expiry, limits, call scopes, and witness. On the registered path, the verifier reads them from the AccountKeychain.

### When the registrable path fails

Each failing row is a case in which the signed key authorization cannot register the access key, so the registrable path must return false. The first row is the registered path, for comparison. The next seven depend on chain state and can change between blocks: the gas row depends on the account's nonce, key type, and configuration. The last three depend only on the authorization's contents, so they fail at every block.

| Case | Transaction outcome | Verification |
|---|---|---|
| Access key registered and active | n/a | True at step 3 |
| Access key registered and expired | `KeyAlreadyExists` | False at step 6 |
| Access key revoked | `KeyAlreadyRevoked` | False at step 6 |
| Witness burned | Witness check fails | False at step 6 |
| Authorization expired | Expiry check fails | False at step 5 |
| Account's root key retired | Signer check fails | False at step 5 |
| `key_id` is a retired root key | `InvalidSignatureType` | False at step 5 |
| Intrinsic gas over the transaction gas limit cap | Rejected at validation | False at step 5 |
| Duplicate token, or a limit above `2^128 - 1` | `InvalidSpendingLimit` | False at step 5 |
| Invalid call scope or selector rule | `InvalidCallScope` | False at step 5 |
| Limits or call scopes over the transaction pool's caps | Rejected by the transaction pool | False at step 5 |

### Invalidation

Before registration the account cannot call `revokeKey`: it reverts with `KeyNotFound` for an access key that was never registered. To stop a pre-authorized signature, the account can:

- **Burn the witness** with `burnKeyAuthorizationWitness` ([TIP-1053](./tip-1053.md)). This works only for an authorization that has a witness, and only before registration. A burn leaves a registered access key active, so step 3 still accepts its signatures.
- **Register, then revoke.** Send a transaction that registers the access key, then call `revokeKey`.

Wallets should include a witness and an expiry in every key authorization that an app may use before registration.

## Interfaces

This TIP adds no precompile function, event, error, or storage. A verifier uses these existing views:

| Precompile | Function | Step |
|---|---|---|
| SignatureVerifier | `verifyKeychain(address account, bytes32 hash, bytes signature)` | 3 |
| SignatureVerifier | `recover(bytes32 hash, bytes signature)` | 4 |
| SignatureVerifier | `verify(address signer, bytes32 hash, bytes signature)` | 5 |
| SignatureVerifier | `verifyConfigurableAccount(address account, bytes32 hash, bytes signature)` | 5 |
| AccountKeychain | `isAdminKey(address account, address keyId)` | 5 |
| AccountKeychain | `getKey(address account, address keyId)` | 6 |
| AccountKeychain | `isKeyAuthorizationWitnessBurned(address account, bytes32 witness)` | 6 |

A verifier may recover primitive signatures locally instead of calling `recover` and `verify`.

# Compatibility

No node, transaction, or precompile behavior changes. Adoption is in signers and verifiers.

| Integration | What changes |
|---|---|
| Signers (Viem and `ox` access key accounts) | Return a pre-authorized signature from message and typed data signing while the account holds an unregistered signed key authorization. Return the plain keychain signature after registration. |
| Offchain verifiers (Viem `verifyHash`, `verifyMessage`, `verifyTypedData`) | Detect `magic` and run [Verification](#verification). Run the Tempo check before the ERC-6492 and ERC-8010 checks, whose magic values differ. |
| Servers that verify proofs from access keys | Accept a pre-authorized signature wherever they accept a keychain signature today. |
| Verifiers without this TIP | Fail to parse the signature and return false, so the failure is closed. |
| Contracts | No change. See [Scope limits](#scope-limits). |

The `ox` signature envelope has its own internal magic suffix, `0x77` repeated 32 times. `ox` removes it before a signature leaves the library. It is unrelated to this format and never appears inside it.

A plain keychain signature cannot be mistaken for a pre-authorized signature. A secp256k1 or P256 inner signature ends in a recovery or pre-hash byte. A WebAuthn inner signature ends in a public key coordinate, and a coordinate equals `magic` with negligible probability.

# Observability

N/A. Verification is offchain and emits nothing. Registration emits the existing `KeyAuthorized` event, and burning a witness emits the existing `KeyAuthorizationWitnessBurned` event.

# Threat Model

The most consequential attack is a verifier accepting a signature from an access key that the account has tried to stop. Before registration the account cannot revoke the access key. The window stays open until the authorization expires, its witness is burned, or the account registers and revokes the key. See [Invalidation](#invalidation).

| Actor | Controls | Enforcing check | Consequence of failure |
|---|---|---|---|
| Holder of the access key | The access key and the signed key authorization | Steps 4 and 5: the root key approved this access key for this chain and account | None beyond what the root key granted. The holder could register the access key with one transaction and then pass `verifyKeychain`. |
| Holder of only the signed key authorization, such as a past verifier | A public approval | Step 4: the inner signature must come from the access key | None. |
| Holder of a stopped access key | An access key that was revoked, or an unregistered access key whose witness was burned | Steps 3 and 6 | The verifier accepts a signature that the chain refuses. |
| Node behind the verifier | The state the verifier reads | None. See [Assumptions](#assumptions). | A hidden revocation or burned witness. |

Residual risks:

- **No limits on messages.** Spending limits and call scopes bind transactions, not signed messages, as with `verifyKeychain`. A verifier that needs a narrower grant reads the limits and scopes from the decoded authorization and applies its own policy.
- **Disclosure.** The verifier learns the access key's expiry, limits, call scopes, and witness before they are onchain.
- **Divergence.** A verifier that omits a check in step 5 or 6 accepts signatures that can never register. Implementations must cover every row of [When the registrable path fails](#when-the-registrable-path-fails).
- **Size.** A signed key authorization with many call scopes is large. A verifier should bound the signature length before it decodes.

# Invariants

- A signature that does not end in `magic` verifies exactly as before this TIP.
- A pre-authorized signature is valid only if the root key, or the owner quorum, of the account signed a `key_authorization` for the key that signed the message.
- If verification returns true on the registrable path at a block, a transaction from the account that carries the same signed key authorization passes every `key_authorization` check in the next block, unless that block's timestamp reaches `expiry` or an earlier transaction in it changes the AccountKeychain state of the account.
- If the access key is revoked or expired in the AccountKeychain, verification returns false. If the access key is not registered and its witness is burned, verification returns false.
- A pre-authorized signature for one account, chain, or hash is invalid for any other.
- Decoding never accepts trailing bytes, a non-canonical RLP encoding, or a list with more or fewer than two items.
- A node rejects a transaction whose signature is a pre-authorized signature.

Test cases must cover: each row of the registration state table; each primitive key type; a `key_type` that differs from the inner signature type; each `limits` and `allowed_calls` check in step 5, at and over each transaction pool limit; an authorization within every cap whose intrinsic gas exceeds the transaction gas limit cap; a `key_id` that is a retired root key; a `user_address`, `account` field, or `chain_id` mismatch; an authorization signed by a key other than the root; a configurable account as the root; the registered path with an unrelated authorization attached; and the example vector above.
