Encryption with Seal
This guide continues from Data Storage Using Walrus and builds on the same OnlyFins app. The Walrus guide covers how OnlyFins stores and reads images. This guide covers how it protects them: encrypting images before upload and enforcing onchain access control so only authorized users can decrypt them.
By the end of this guide, you understand how to design a Seal access control policy in Move, how to encrypt data before uploading it to Walrus, and how to implement the full client-side decryption flow in a browser app.
Encrypt-upload-decrypt sequence
Seal should not be used to protect highly sensitive data such as wallet keys, personal health information, or government-level secrets. Review the Seal Terms of Service and security best practices before deploying to production.
Introduction to Seal
Seal is a decentralized secrets management (DSM) service that uses access control policies defined and validated on Sui. You use Seal to encrypt sensitive data before storing it on Walrus, onchain, or in any other storage, and then issue decryption keys only to users who satisfy your onchain access conditions.
Seal has 3 core components:
- Onchain access policies: Move functions you write that define who is allowed to decrypt. Key servers evaluate your policy by running a
dry_run_transaction_blockon Sui when a user requests decryption key shares. If the policy approves, the key shares are returned. - Key servers: Offchain services that hold IBE (identity-based encryption) master secret keys. Each server returns derived decryption key shares only when the request satisfies the associated onchain policy. You configure a threshold, so decryption requires at least
tout ofnservers to agree. - Client-side encryption: The user encrypts and decrypts data locally in their environment. Key servers never see plaintext.
In OnlyFins, the access policy is: the user must own a ViewerToken object for the specific post they want to unlock. The user receives a ViewerToken, a Sui object, after requesting access onchain. Seal key servers check this ownership before issuing key shares.
How Seal fits with Walrus
Walrus stores all blobs publicly. Seal provides the encryption layer on top. The workflow is:
- Encrypt the image locally with Seal before upload.
- Store the encrypted blob on Walrus. The blob ID is stored on the
PostSui object. - When you want to view a post, you build a transaction that proves you own a
ViewerToken. - Seal key servers verify the transaction against the onchain policy and return key shares.
- The user combines the key shares to derive the decryption key and decrypts the image locally.
The blob on Walrus is always public. What is secret is the encryption key, and access to that key is controlled entirely by the onchain policy.
Tooling
2 tools support Seal integration: the CLI for local testing and the TypeScript SDK for application development.
Seal CLI
The Seal CLI (seal-cli) lets you encrypt and decrypt data, generate key pairs, inspect encrypted objects, and fetch keys for testing. It is useful for verifying your Move policy and testing key server connectivity before integrating the SDK. See the Seal CLI documentation for usage.
Seal TypeScript SDK
The @mysten/seal package provides SealClient for encryption and decryption, and SessionKey for managing user-approved access sessions.
Install the SDK:
$ npm install --save @mysten/seal @mysten/sui
The @mysten/seal package provides 2 primary classes:
SealClient: Handles encryption, key fetching, and decryption. Can be instantiated standalone or as a Sui client extension.SessionKey: Represents a user-approved session that allows a browser app to fetch decryption keys without triggering a wallet popup on every request. A session has a time-to-live (TTL) and is scoped to a specific package ID.
OnlyFins uses SealClient in standalone mode. The createSealClient factory in seal-client.ts initializes it with the Testnet key server object IDs:
frontend/src/lib/seal-client.ts. You probably need to run `pnpm prebuild` and restart the site.Some code examples in this guide use older Sui transports: OnlyFins uses SuiClient from @mysten/sui/client, and the seal-demo standalone script uses SuiJsonRpcClient from @mysten/sui/jsonRpc, which is deprecated. SuiGrpcClient from @mysten/sui/grpc is the recommended transport for all new integrations. All SealClient constructor and encrypt/decrypt APIs are identical regardless of which transport you use. See the Sui SDK v2 migration guide for upgrade instructions.
The key server object IDs come from constants.ts. These IDs point to onchain KeyServer objects that always hold the latest URL as the source of truth. See the Seal documentation for the full list of verified key servers and their modes (open vs. permissioned).
frontend/src/constants.ts. You probably need to run `pnpm prebuild` and restart the site.Access control management
Access control in Seal is defined by Move functions you write in your app's package. Your Seal-integrated Move module must expose at least one entry fun seal_approve* function. Key servers call this function through dry_run_transaction_block to determine whether to issue key shares. If the function aborts, access is denied. If it completes successfully, access is granted.
The seal_approve convention
Write a seal_approve function that follows these rules:
- It must be an
entry fun, not a public function. - Its first parameter must always be
id: vector<u8>. This is the inner identity. Seal prepends the package ID to form the full namespaced identity. - It should abort with a meaningful error code if the caller does not satisfy the access condition.
- It must not be called from other programmable transaction block (PTB) commands during Seal evaluation. Only
seal_approve*functions are invoked directly.
You pass the identity for each encrypted object as the id value at encryption time. The same id must be used in the seal_approve PTB at decryption time.
OnlyFins access control: ViewerToken gating
OnlyFins implements a purchase-to-access pattern. Each encrypted post has an associated encryption_id. To decrypt a post, the user must prove ownership of a ViewerToken object whose post_id field matches the post being unlocked.
The seal_approve_access function in the posts module enforces this:
frontend/move/sources/posts.move. You probably need to run `pnpm prebuild` and restart the site.This function receives the Seal ID, the Post object, and the user's ViewerToken. It checks that the ViewerToken is issued for the correct post and that the encryption ID matches. If either check fails, it aborts and the key server denies the request.
Acquiring a ViewerToken
The onchain transaction mints a ViewerToken when a user requests access to a post. The usePayForContent hook builds and executes this transaction:
frontend/src/hooks/usePayForContent.ts. You probably need to run `pnpm prebuild` and restart the site.After the transaction succeeds, the user sees the ViewerToken object in their wallet. Seal key servers now approve decryption requests for that post from that wallet address.
Other access control patterns
OnlyFins uses a purchase-gated pattern, but Seal's seal_approve mechanism is fully generic. The seal-demo in the Sui Move Bootcamp provides 3 minimal working implementations of the most common patterns.
Owner-only access
The most basic policy: only the wallet address encoded in the identity decrypts the content. The entire seal_approve function is a single assertion comparing the Binary Canonical Serialization (BCS)-encoded caller address to the id bytes:
K5/seal-demo/move/sources/private_seal.move. You probably need to run `pnpm prebuild` and restart the site.Time-lock access
Users can only decrypt data after a specific timestamp has passed. The id encodes a u64 unlock time in milliseconds. The seal_approve function peeks the timestamp from the identity bytes and checks it against the Sui Clock object:
K5/seal-demo/move/sources/timelock_seal.move. You probably need to run `pnpm prebuild` and restart the site.Allowlist access
A shared Allowlist object holds a list of authorized addresses that an admin manages. The seal_approve function checks that the caller is a member. Because the allowlist is a shared object, the admin can add or remove members at any time without re-encrypting the content:
K5/seal-demo/move/sources/allowlist_seal.move. You probably need to run `pnpm prebuild` and restart the site.For subscription-based access (time-bound paid access) and other patterns, see the Seal repository's examples/move directory.
Encrypting
Encryption happens before upload, entirely on the client side. You never send the data in plaintext.
How Seal encryption works
SealClient.encrypt uses identity-based encryption (IBE). Each piece of content is encrypted to a specific identity, which is a byte string composed of your package ID (prepended automatically by Seal) and the id you provide. The same id is later used in the seal_approve PTB to gate decryption.
The encrypt call returns the encryptedObject (the ciphertext) and a backup key. You can use the backup key for disaster recovery with the seal-cli symmetric-decrypt command. Store or discard it according to your recovery requirements.
OnlyFins server-side encryption
In OnlyFins, encryption runs in the backend encryptImages.ts script before images are uploaded to Walrus. The script generates a unique encryptionId for each post, encrypts the image bytes, and saves the ciphertext to disk for CLI upload:
backend/src/encryptImages.ts. You probably need to run `pnpm prebuild` and restart the site.A few things to note about the OnlyFins encryption setup:
- The
encryptionIdis a hex-encoded string derived from a timestamp and post index. This value becomes the Sealidparameter, which must later be passed toseal_approve_accessduring decryption. - The
thresholdis 1, meaning a single key server is sufficient to issue decryption keys. In production, a higher threshold distributes trust across multiple servers. - The
packageIdties the encrypted object to the specific deployed Move package. Seal prepends this to theidto form the full namespaced identity.
The encryptionId is then stored as the encryption_id field on the Post Sui object when createPosts.ts registers the post onchain. This links the Walrus blob, the Seal encryption, and the Sui object together.
Seal supports envelope encryption for large files. Generate a symmetric key, encrypt the image data with AES, then encrypt only the symmetric key with Seal. This is more efficient for large payloads and is the recommended pattern for anything larger than a few hundred KB. See the Seal documentation for details.
Browser-side encryption
For React apps, the useSealEncrypt hook from walrus-pocs shows how to encrypt a message in the browser using a nonce-based key ID derived from the sender's address:
walrus-seal/app/src/hooks/useSealEncrypt.ts. You probably need to run `pnpm prebuild` and restart the site.The underlying encryptData utility in sealUtils.ts wraps SealClient.encrypt in a reusable form that works across both browser and Node.js contexts:
walrus-seal/app/src/utils/sealUtils.ts. You probably need to run `pnpm prebuild` and restart the site.Standalone script: full encrypt and decrypt flow
The seal-demo includes a Node.js script that shows the complete Seal workflow in a single file: client setup, encryption, session key creation, PTB construction, and decryption, using the owner-only access pattern:
K5/seal-demo/ts/src/index.ts. You probably need to run `pnpm prebuild` and restart the site.Decrypting
Decryption in OnlyFins is entirely client-side. Your browser fetches the encrypted blob from the Walrus aggregator, requests key shares from Seal key servers, derives the decryption key, and decrypts the image locally. Key servers never see the ciphertext or the plaintext.
Session keys
A SessionKey is a short-lived credential that authorizes a browser app to fetch decryption keys from Seal key servers without triggering a wallet popup on every request. The user approves a session once per package, signing a personal message in their wallet. You configure the TTL for which the session is valid.
The useSealSession hook from walrus-pocs shows the general-purpose pattern: create a SessionKey, prompt the user to sign, activate the session, and auto-clear it when the wallet disconnects or switches:
walrus-seal/app/src/hooks/useSealSession.ts. You probably need to run `pnpm prebuild` and restart the site.The createAndSignSessionKey utility in sealUtils.ts shows the core signing flow in isolation, with notes on signature format requirements for both Sui wallets and non-Sui wallets:
walrus-seal/app/src/utils/sealUtils.ts. You probably need to run `pnpm prebuild` and restart the site.In OnlyFins, the same session key lifecycle is managed through useSessionJWT.ts and a SessionKeyProvider context, which ties session creation to Enoki-based JWT authentication rather than direct wallet signing.
The full decryption flow
The useSealDecrypt hook from walrus-pocs shows the general-purpose decryption pattern: fetch owned PrivateData objects, build the approval PTB, and call sealClient.decrypt:
walrus-seal/app/src/hooks/useSealDecrypt.ts. You probably need to run `pnpm prebuild` and restart the site.In OnlyFins, the usePostDecryption hook implements the same flow scoped to the OnlyFins Post and ViewerToken objects:
frontend/src/hooks/usePostDecryption.ts. You probably need to run `pnpm prebuild` and restart the site.The flow has 4 steps:
-
Fetch the encrypted blob.
fetchFromWalrusretrieves the raw encrypted bytes from the Walrus aggregator using the blob ID stored on thePostobject. -
Build the approval transaction. A PTB is constructed that calls
posts::seal_approve_accesswith the encryption ID, thePostobject, and the user'sViewerToken. This transaction is never executed onchain. It is passed astxBytestofetchKeys, where the key server usesdry_run_transaction_blockto evaluate the policy. -
Fetch key shares.
sealClient.fetchKeyssends the PTB bytes and the session key to the configured key servers. Each server that validates the policy returns a key share. When enough shares are gathered to meet the threshold, the decryption key can be derived. -
Decrypt locally.
sealClient.decryptcombines the key shares to reconstruct the decryption key and decrypts the image bytes. The decrypted bytes are converted to a blob URL and set as the image source in the component.
The checkShareConsistency flag is set to false in OnlyFins. For production apps handling sensitive data, set it to true to verify that key shares from different servers are consistent before combining them.
Failure modes
| Error | Cause | Resolution |
|---|---|---|
| Session key expired | TTL elapsed since wallet approval | Re-create session key; user re-approves |
| Key server unreachable | Network issue or server offline | Check server status; retry with exponential backoff |
| Decryption fails after grant | ViewerToken exists but encryption_id mismatch | Verify encryption_id matches the value stored on the Post object |
seal_approve aborts | Access condition not met | Confirm the user owns the required object; check Move policy logic |
| Threshold not met | Fewer than t servers responded | Increase timeout; check key server configuration |