Skip to main content

Encrypting data with Seal

Walrus stores blobs publicly: anyone with a blob ID can read the bytes. To keep data confidential, encrypt it before you store it. This tutorial walks through a complete round trip with Seal: encrypt a message, store the ciphertext on Walrus, read it back, and decrypt it, with working TypeScript for both directions.

Seal handles encryption and onchain access control; Walrus handles storage. The two are independent, so this page stays focused on how they fit together and links to the Seal documentation for the full API and security model. For the concepts behind the code, see Data Security.

How the pieces fit

  1. Encrypt the data with Seal. Seal applies threshold encryption: the decryption key is split across key servers, and no single server can decrypt on its own.
  2. Store the resulting ciphertext on Walrus. Only encrypted bytes ever reach Walrus.
  3. Read the ciphertext back from Walrus by its blob ID.
  4. Decrypt with Seal. The key servers release decryption shares only after an onchain seal_approve policy confirms the requester is allowed.

Prerequisites

  • Node.js and the @mysten/sui, @mysten/seal, and @mysten/walrus packages.
  • Testnet SUI for gas and WAL for storage. Acquire WAL with walrus get-wal.
  • An access-control policy deployed onchain that exposes a seal_approve function. This tutorial uses the allowlist pattern from the Seal repository. Deploy allowlist.move, create an allowlist, and add your address to it. The Move side is documented in full under Using Seal; this page does not duplicate it.
Why a Move policy is required

Seal does not decide who may decrypt; your seal_approve function does. The key servers evaluate it against current onchain state before releasing shares. The allowlist pattern grants access to a fixed set of addresses, but any Move logic works: token gating, time locks, or custom conditions. See access-control options.

Configuration

All four scripts share one Sui client (extended with the Walrus SDK), one Seal client, and the policy IDs you got when you deployed the allowlist. Seal key servers are network-specific, so the client, the policy, and the key servers must all be on the same network.

The keypair helper supplies a Testnet account with SUI and WAL. In a browser app you would sign with a wallet through @mysten/dapp-kit instead.

Encrypt and store

Encryption needs a Seal identity: an arbitrary byte string that must begin with your policy's namespace so the seal_approve prefix check passes. For the allowlist pattern the namespace is the allowlist object's ID; appending a random nonce gives each message a distinct identity under the same policy.

The core call is sealClient.encrypt, which returns the encrypted object to store and a symmetric backup key you can ignore:

const { encryptedObject } = await sealClient.encrypt({
threshold: THRESHOLD,
packageId: PACKAGE_ID,
id,
data: message,
});

The full script encrypts the message and stores only the ciphertext on Walrus:

Run it and note the printed blob ID:

SUI_PRIVATE_KEY=suiprivkey1... npx tsx encrypt-and-store.ts

Read and decrypt

Decryption has three moving parts: a SessionKey authorized once by the user's signature, a PTB that calls seal_approve so the key servers can check the policy, and the decrypt call itself.

The PTB must have its sender set to the requesting address. The allowlist policy checks ctx.sender(), so a missing or mismatched sender fails with Transaction was not signed by the correct sender:

const tx = new Transaction();
tx.setSender(address);
tx.moveCall({
target: `${PACKAGE_ID}::${MODULE_NAME}::seal_approve`,
arguments: [tx.pure.vector('u8', fromHex(id)), tx.object(ALLOWLIST_ID)],
});
const txBytes = await tx.build({ client: suiClient, onlyTransactionKind: true });
const decrypted = await sealClient.decrypt({ data: encryptedBytes, sessionKey, txBytes });

The full script reads the blob back from Walrus, recovers the identity from the encrypted object, authorizes a session key, fetches decryption shares, and decrypts:

Run it with the blob ID from the previous step:

SUI_PRIVATE_KEY=suiprivkey1... npx tsx read-and-decrypt.ts <blobId>

If the requesting address is on the allowlist, the original message prints. If not, the key servers refuse and the SDK throws NoAccessError.

Troubleshooting

  • Transaction was not signed by the correct sender — call tx.setSender() with the requester's address before building the PTB, and make sure that address matches the session key's address.
  • Requested package is not supported — the key servers do not recognize your package on this network. Confirm the package is published on the same network as the key servers and that PACKAGE_ID is correct.
  • NoAccessError — the policy denied access. For the allowlist pattern, confirm the requesting address was added to the allowlist.
  • InvalidParameter right after creating objects — a key server's full node may not have indexed a just-created object yet. Wait a few seconds and retry.

For the complete Seal API, key-server selection, and the security model, see Using Seal.