Securing app state, right and wrong
Task: make every rule your app states (“only the author edits”, “moves can’t be taken back”, “only admins ban”) hold for real, not just in your UI.
We reviewed every app we ship against the model below and found the same dozen mistakes again and again. If they were in our apps, they will be in yours. Each section here is one of them: the wrong shape, why it fails, and the shape that holds.
The one fact everything follows from
Section titled “The one fact everything follows from”Your contract runs on every member’s own node. Any member can run a patched node that skips every line of your method bodies and writes storage entries directly. So there are exactly two kinds of rule:
| Kind | Where it lives | Holds against a patched node? |
|---|---|---|
A check in your method (if caller != author { bail!() }) |
your WASM, on the caller’s node | No. It is UX: a good error before a doomed write |
A storage type’s rule (Authored, WriteOnce, Moderated, Frozen, UserStorage, SharedStorage, AccessControl, PermissionedStorage, Registry) |
checked by every node when it applies a peer’s write | Yes |
A plain UnorderedMap, SortedMap, Vector, UnorderedSet, LwwRegister,
Counter or plain root field is Public: any member can create, overwrite
or delete any entry, and every node accepts it. That is right for data everyone
edits together, and wrong for anything with an owner.
Keep your method checks: they give honest users a readable error before a write every other node would refuse. Just never mistake them for the rule.
1. A permission check guarding a public collection
Section titled “1. A permission check guarding a public collection”Wrong. A role table and a check, over a plain map:
roles: UnorderedMap<String, LwwRegister<String>>, // "admin" / "editor" / "viewer"
pub fn set_role(&mut self, who: String, role: String) -> app::Result<()> { if self.role_of(&caller())? != "admin" { app::bail!(Error::NotAdmin); // skipped by a patched node } self.roles.insert(who, role.into())?; Ok(())}A patched member writes roles[me] = "admin" directly, or deletes the real
admin. The same goes for a banned map, a managers map, a protections list,
or any “only the owner may” check over a plain collection.
Right. Put the rule in a type every node enforces:
roles: AccessControl, // admins tier + named roles
#[app::init]fn init() -> Self { Self { roles: AccessControl::new_admin_caller(), .. } }
pub fn grant_editor(&mut self, who: AccountId) -> app::Result<()> { self.roles.grant("editor", who)?; // every node checks the grantor is an admin Ok(())}- Roles and admins:
AccessControl. - A value only a set of people may write (a ban list, settings, a board
name):
SharedStorage<T>, whose writer set you rotate when roles change. An owned collection in the value (SharedStorage<AuthoredMap<K, V>>) gives each writer its own entries: every node takes an entry there only from its owner, and only while the owner is one of the cell’s writers. - Data only some roles may write (a viewer who must not edit): wrap it in
PermissionedStorage<T, ProtocolAuthorizer>and callroles.project_onto(&[("editor", OpMask::WRITE.union(OpMask::DELETE))], &mut data)after every grant and revoke.
Also:
- Key roles by account (
env::account_id()), never by device. A person has several devices; a device-keyed role table lets a demoted viewer open the app on a second device and get whatever the default is. - Never default an unknown caller to a privileged role.
.unwrap_or(Role::Editor)hands edit rights to anyone the table does not know.
2. Trusting an identity field inside the value
Section titled “2. Trusting an identity field inside the value”Wrong. The value says who wrote it:
pub struct Message { pub sender: String, pub text: String }messages: UnorderedMap<String, Message>,
fn can_edit(&self, m: &Message) -> bool { m.sender == caller() } // m.sender is forgeablesender, author, created_by, owner, voter, account: whatever the
field is called, the writer chose it. A patched node writes
Message { sender: "alice", .. } and your UI shows Alice said it, and your edit
check lets the forger edit “Alice’s” message.
This is worse when the value decides security: a DeviceKey { account, .. }
field that the client trusts to decide who gets a vault key is a key leak.
Right. Store the entry in an owned collection and read who wrote it from the stamp, which every node verified against the writer’s signature:
messages: Authored<SortedMap<String, Message>>, // Message has no sender field
for (author, key, message) in self.messages.entries_with_owners()? { // `author` is the verified writer}let theirs = self.messages.get_by(&author, &key)?; // one author's entry, by nameKeys of an owned collection are per owner: each account has its own
namespace, so alice and bob can both hold "m1", and they are two entries.
The key-only methods (get, contains, update, remove, owner_of) act on
the caller’s entry; name anyone else with get_by(&owner, &key).
If you keep the field for display, check it against the owner on read and drop rows where they differ.
3. Maps keyed by identity: squatting
Section titled “3. Maps keyed by identity: squatting”Wrong. One row per person, keyed by their id, in a public map:
profiles: UnorderedMap<AccountId, Profile>,players: UnorderedMap<MemberId, Player>,votes: UnorderedMap<String /* "{post}|{voter}" */, Vote>,Anyone can write anyone’s row: rename a user, evict a player, vote in someone else’s name.
Half right. AuthoredMap<AccountId, Profile> stops anyone changing your
row, but anyone can hold a row keyed by your account in their own
namespace. A patched node inserts profiles[victim] as itself, and a reader
that iterates entries() or trusts the key sees a profile the victim never
wrote. It never blocks the victim’s own row, which is a different entry.
Right. Either:
- One slot per account, assigned by the account itself:
UserStorage<T>.insertwrites the caller’s own slot; nobody can write another’s.profiles: UserStorage<Profile>,self.profiles.insert(profile)?; // my slotself.profiles.get_for_user(&account)?; // anyone's, read-only - Or an owned collection plus a key check on read: accept a row only
when the account in its key is the account that owns it.
for (owner, key, vote) in self.votes.entries_with_owners()? {if owner != account_in(&key) { continue; } // squattedif !(-1..=1).contains(&vote.value) { continue; } // out of rangescore += i64::from(vote.value);}// or, for one voter: self.votes.get_by(&voter, &key)?
The second shape is what stops vote stuffing: an attacker can still create extra rows under keys they invent, but every one of them fails the key check.
4. History that can be rewritten
Section titled “4. History that can be rewritten”Wrong. Moves, shots, signatures, audit entries, activity logs or messages kept somewhere their author (or anyone) can update or delete:
moves: AuthoredMap<String, MoveRecord>, // the author can update or removesignatures: UnorderedMap<String, Vector<Signature>>, // anyone can push or deleteactivity: SortedMap<String, ActivityRecord>, // anyone can rewrite the audit logA player undoes a move or a resignation after seeing the reply; a signer’s signature disappears; the audit log says whatever the last vandal wanted.
Right. WriteOnce<C>: the author owns the entry, and nobody, the author
included, can change or delete it, on any node.
moves: WriteOnce<SortedMap<String, MoveRecord>>,signatures: WriteOnce<SortedMap<String /* "{doc}/{account}/{nonce}" */, Signature>>,activity: WriteOnce<SortedMap<String, ActivityRecord>>,“Nobody” has one edge: the author’s own devices. A key belongs to an account, so two of its devices that write the same key before either sees the other’s write both reach every node, and every node keeps the same one: the write with the earlier signed timestamp, ties going to the lower content hash. A later rewrite always loses, but the timestamp has no lower bound, so a patched client of the author can replace its own entry by signing a backdated write. No other account can, however early its write.
Two follow-ups make an append-only log actually safe:
- Derive state, don’t store it.
turn,winner,status: FullySigned,current_game: compute them from the log on read. A storedwinnerregister is one more thing anyone can overwrite. - Decide what a second entry means. Immutability stops rewrites, not extra entries. If an author appends two different moves for the same ply, that is equivocation: make your reader treat it as a forfeit, or freeze the game. Never break the tie on a timestamp the author chose (see section 8).
If the content itself is the identity (evidence, uploads, a signed original),
use ContentAddressed<C>: the key is the SHA-256 of the value, and every node
refuses an entry whose key does not match.
5. Settings fixed at creation that anyone can change
Section titled “5. Settings fixed at creation that anyone can change”Wrong. Game rules, a world seed, the players of a match, a workbook’s owner or a poll’s creator, as registers with a “set once” check:
seed: LwwRegister<u64>,owner: LwwRegister<String>,
pub fn claim(&mut self) -> app::Result<()> { if !self.owner.get().is_empty() { app::bail!(Error::Taken); } // UX only self.owner.set(caller()); Ok(())}A patched member rewrites the seed and every client regenerates a different world; two members “claim first” concurrently and last-write-wins picks one.
Right. Write it once, in init, into Frozen<T>. No node accepts a change
or a removal afterwards, even from whoever created it:
world: Frozen<WorldMeta>,
#[app::init]fn init(name: String, seed: u64) -> Self { Self { world: Frozen::new(WorldMeta { name, seed }), .. }}
let seed = self.world.get()?.seed; // there is no setlet founder = self.world.writer(); // whoever ran init6. Moderation built from a public “deleted” set
Section titled “6. Moderation built from a public “deleted” set”Wrong. Moderators “delete” by adding the id to a set everyone reads:
deleted_messages: UnorderedSet<String>,Any member can censor anyone by inserting an id, or undo a moderator by removing
one. A deleted: bool that merges by OR is worse: one forged write tombstones a
post forever and not even its author can restore it.
Right. Moderated<C>: the author edits and deletes their own entry, and a
rotatable moderator set may delete anyone’s. Every node checks a moderator’s
delete against the moderators as of that delete.
posts: Moderated<IndexedMap<String, Post>>,
#[app::init]fn init() -> Self { Self { posts: Moderated::new(), .. } } // the founder is the first moderator
self.posts.remove(&id)?; // the author's own entryself.posts.remove_by(&author, &id)?; // any moderator, anyone's entryself.posts.set_moderators(accounts)?; // only a moderatorUse ModeratedOnce<C> when entries must also be immutable (announcements,
signed chat): nobody edits them, and only a moderator removes one, not even its
author. A removal is final: the author can never write that key again, from any
device, however early or late the write claims to be, and every node ends with
the key deleted whatever order the writes and the removal reach it in. Give a
re-post a new key.
7. Readers that trust what writers control
Section titled “7. Readers that trust what writers control”Every value a reader uses came from some member’s node. A reader must treat it as input, not truth.
Wrong:
// a sweep that counts only successful deleteswhile seq <= cursor.next_seq && removed < MAX_PRUNE { if self.chunks.remove(&key(seq))?.is_some() { removed += 1; } seq += 1;}One forged cursor with next_seq = u64::MAX and no chunks behind it makes every
honest node loop about 2⁶⁴ times: one row stops the app for everyone.
Right: bound loops by steps, not by successes, and validate on read:
let mut steps = 0;while seq <= cursor.next_seq && steps < MAX_PRUNE { self.chunks.remove(&key(seq))?; seq += 1; steps += 1; }Validate everything else the same way: coordinates within bounds, a vote in
-1..=1, a string under its length cap, a referenced id that exists. A size cap
in your insert method is UX; the same cap in your reader is the rule.
8. Ordering by a timestamp the writer chose
Section titled “8. Ordering by a timestamp the writer chose”Wrong. “Earliest claim wins”, “latest edit wins”, “the room clock is the
newest heartbeat”, all using claimed_at, updated_at or now values that
arrive from the writer:
let holder = claims.iter().min_by_key(|c| c.claimed_at); // claimed_at: 0 wins every seatlet room_now = players.iter().map(|p| p.updated_at).max(); // one u64::MAX poisons itRight:
- Don’t elect by a writer’s timestamp. Give each seat its own key in a
WriteOncemap, so there is exactly one holder, with no ordering at all. When many accounts want the SAME seat or name, use aRegistry: an authority decides, and ties it sees split by a hash no clock can move. A node refuses a timestamp from the future and never one from the past, so “lowestclaimed_atwins” hands the seat to whoever writes0. - Clamp what you must accept. When a timestamp only orders honest edits,
ignore or cap values more than a short window ahead of the reader’s own
clock, so
u64::MAXcannot pin an entry forever.
9. Ids from a shared counter
Section titled “9. Ids from a shared counter”Wrong:
next_id: Counter,let id = format!("doc-{}", { self.next_id.increment()?; self.next_id.value()? });Two nodes creating at the same time both mint doc-7. The two records merge
into one, or one silently replaces the other.
Right: derive ids from something only the creator has: "{account}-{n}"
with a per-account counter, a hash of (account, time, nonce), or the content
hash. Keys like that also cannot be squatted by someone else.
10. A commitment nobody can check
Section titled “10. A commitment nobody can check”Wrong. Commit to a hidden board with sha256(board ‖ salt), then have the
defender self-report hits and verify the commitment only on their own node. A
cheater answers “miss” forever.
Right. At the end, each player publishes (board, salt) into a slot only
they own and nobody can change (WriteOnce, or an immutable UserStorage
value). Every reader recomputes the hash, replays the answers against the
board, and checks the board was legal. A lie, or a missing reveal, loses. A
commitment is only as good as the check other nodes run on it.
11. Locking data that should stay open
Section titled “11. Locking data that should stay open”The opposite mistake is just as real. Collaborative data must stay writable by everyone:
- cells, text and document bodies (
FugueText,RichDocument), board elements between editors, blocks in a shared world, CRM deals, issue triage fields.
Wrapping such a record in Authored also makes the collections nested inside
it owner-only: a nested collection inherits its entry’s owner, at any depth.
That is the protection you want for a post’s attachments and exactly the wrong
thing for a shared document’s body. When one record mixes the two, split it: a
protected header (creator, delete rights) in Moderated, and the shared fields
in a public map.
Inside WriteOnce, ModeratedOnce and ContentAddressed entries a nested
collection is sealed: it can only be stored empty and never written.
12. Merges that lose concurrent edits
Section titled “12. Merges that lose concurrent edits”Not a security hole, but the same review found it everywhere:
- A map entry holding a struct resolves last-write-wins on the whole entry.
Two people editing different fields of one record at once keep only one edit.
Put fields edited concurrently in their own entries or registers, or make the
type
#[app::mergeable]. score + 1in a register loses concurrent increments. Use aCounter, or a set of voters whose size is the score.- A
Vecinside an entry loses concurrent appends (replies, tags). Use anUnorderedSet, or a map keyed"{parent}|{time}|{id}".
13. Full scans in list views
Section titled “13. Full scans in list views”Also not security, but a patched member who floods a collection makes every scan slower for everyone, so it becomes a liveness problem:
- A tally, count or “unread” computed by scanning every row, once per item on a page, is O(items × rows).
IndexedMapwith#[derive(app::Indexed)]turns filters, joins and counts into seeks:posts.query("board_feed").eq(board).desc().limit(20),votes.query("post_id").eq(id).count().SortedMapwith keys like"{parent}|{time}"makes a thread oneprefix(...)slice, and never reads another parent’s rows.- Both combine with the guards:
Authored<IndexedMap<..>>,Moderated<IndexedMap<..>>,WriteOnce<SortedMap<..>>.
Checklist
Section titled “Checklist”Before you ship, for every field in your state:
- Who is allowed to create, change and delete it? If the answer is not
“everyone”, is it in a type that says so (
Authored,WriteOnce,Moderated,Frozen,UserStorage,SharedStorage,AccessControl,PermissionedStorage)? - Does any code trust an identity field in a value instead of the entry’s
owner (
entries_with_owners,get_by)? - Is any map keyed by an identity without
UserStorageor an owner-equals-key check on read? - Can any history be updated or deleted? Is any state stored that could be derived from that history?
- Is anything fixed at creation stored in a register instead of
Frozen<T>? - Does any “deleted”, “banned” or “resolved” flag live in a public collection?
- Does any reader trust a writer’s loop bound, value range, size, reference or timestamp?
- Are ids minted from a shared counter?
- Does any commitment get checked only by the committer?
- Is anything collaborative locked by mistake?
- Are roles keyed by account, with no privileged default?