DynamoDB multi-tenant isolation: three layers and the GSI hole
Written by Abhinav Singh, software engineer. Reviewed by [reviewer]. Last updated October 30, 2026.
DynamoDB has no row-level security, so our multi-tenant isolation is three independent layers that each fail on their own. A request context that raises if no tenant is bound, a repository floor that writes the tenant into every partition key, and an IAM role constrained by dynamodb:LeadingKeys so that AWS itself refuses an item outside the caller's tenant. One place escapes all three: a global secondary index (GSI), whose partition spans every tenant, so every index read carries a tenant filter and the one operation IAM cannot constrain, Scan, is forbidden outright.
This post covers what each layer actually checks, the GSI hole and the read that regressed isolation while making a list faster, the cross-tenant reads that are allowed on purpose, and the IAM condition semantics that make the scan ban necessary.
The short version
- Isolation is 3 layers, context, repository floor and IAM, and a tenant can only be read across if all 3 fail at once.
- Every partition key is
TENANT#<tenant>#<entity>, so a cross-tenant read by key is impossible and a release-gate test on real DynamoDB proves it with 2 tenants sharing 1 entity id. - A GSI partition such as
USER#<id>spans every tenant, so each index read drops any row whose stored tenant does not match, at the cost of reading rows it then discards. - Cross-tenant reads exist for system coordination only, through 1 explicit method, and each re-binds the tenant from the row before it mutates anything.
Why three layers instead of row-level security
The reference architecture we borrowed from runs on Postgres and isolates tenants with row-level security: a policy on the table that the database applies to every statement, whichever code issued it. On DynamoDB there is no equivalent. The database knows partition keys, sort keys and condition expressions, and nothing about who the caller is.
So the property has to be rebuilt from parts that each fail independently. The context layer decides which tenant a request is. The repository floor turns that decision into keys and conditions. The IAM layer makes AWS refuse an item whose partition key does not lead with the caller's tenant, so a bug in the first two layers still cannot read across. Our post on privacy-first admin consoles covers how an operator reads a household under an assumed tenant; this post is the data layer underneath it.
Layer 1: the tenant comes from the token and nothing else
The tenant id never arrives as a request parameter. The JWT guard binds it into a context variable, and the one accessor raises if nothing is bound. There is no default tenant to fall back to, so a code path that forgot to bind fails on its first read instead of reading someone else's partition.
The mechanism is a per-task context variable, so two concurrent requests in the same worker cannot see each other's binding. A header can select a household the caller belongs to, but it never authorizes: a household the caller has no membership in is a 403, and the reserved tenants, the shared one, the staff one and the global one, are refused even when a membership row names them. That last rule exists because early invites had written memberships for the shared tenant, and honoring one would have bound a caller to every customer's partition.
Layer 2: the repository floor
Modules never hold a DynamoDB client. They subclass one base repository, and the base builds every key as pk = TENANT#<tenant>#<entity>, sk = <id>. A listing is a key-condition query on that partition, a point read is a get on that key, and a delete carries ConditionExpression "tenant_id = :tid". The floor also stamps who-and-when provenance on each write, which is why a hard-lint forbids a raw write anywhere outside it, as described in every lint rule is a scar.
Two details matter more than the key shape. First, a point read checks the item's stored tenant_id against the bound tenant after the get and returns nothing on a mismatch, so a row that somehow landed in the wrong partition still does not surface. Second, a transaction parses every item it is handed and rejects any whose pk does not lead with the calling tenant's prefix, which is what lets a module write several rows atomically without the floor losing its property.
Layer 3: IAM LeadingKeys
The data layer runs under a role whose DynamoDB permissions carry a dynamodb:LeadingKeys condition on the caller's tenant prefix. The condition constrains the partition key values an operation presents, so a get, put, query or transaction addressed at another tenant's partition is refused by AWS before the table sees it. The global prefix that holds deployment-wide reference content is the one extension, readable by every role and writable by none of them.
This is the layer that makes the other two honest. A code bug in the floor would have to coincide with a policy bug in IAM to read across, and the two are changed by different people through different review paths. It is also the layer with the narrowest blind spot, covered below.
The GSI hole: an index is global
None of the three layers carries over to a secondary index by itself. A GSI has its own partition key, and that key does not lead with the tenant, because the whole point of the index is to answer a question the tenant partition cannot. A buyer's orders live under USER#<user_id>, and a product's variants under HANDLE#<handle>. Query that partition and DynamoDB returns every tenant's rows in it.
We found this the way these things are found: by making a read faster. The order list used to be a listing of the tenant partition, isolated by its own key. It was replaced by one index query on the buyer's partition, which is cheaper and correctly ordered, since ids are UUIDv7 and sort by creation time. It also returned that buyer's orders from households they were no longer part of, because the same person can belong to two households. Nothing downstream flagged it, since every row was a real order for a real user.
The fix is a tenant filter on the index query, which drops any row whose stored tenant_id does not match. It is a post-filter in application code, not a key condition, so it costs read capacity on rows it then discards, and that is the price of an index whose partition is not scope-qualified. The index attributes are stamped inside the repository's write, not at the call sites, because a row that missed them would read back fine by id and only disappear from the list.
Three layers, and where each stops
| Layer | What it checks | What it cannot see | Who changes it |
|---|---|---|---|
| Context | The tenant is bound from the JWT; reads fail if not | Which key a module builds from it | Application code |
| Repository floor | Partition key leads with the tenant; condition on every mutation; no raw client | A GSI partition, which has its own key | Application code |
IAM LeadingKeys | AWS refuses any item whose partition key is not the caller's tenant | A Scan, which presents no key, and any GSI | Infrastructure code |
The table has two gaps in common. A GSI escapes every layer, which is why the filter above is mandatory rather than defensive. A Scan escapes the IAM layer in particular, because LeadingKeys constrains the keys an operation presents, and a scan presents none. That is why the floor exposes no scan method and a hard-lint fails the build on any scan call inside a module.
The reads that cross tenants on purpose
Some work has to see every tenant. The scheduler's due sweep from our post on how a recurring event fires queries a sparse due partition that holds pending occurrences from every household. The device ingest worker resolves a serial to its owner before any tenant exists. The vet directory searches geohash buckets of a global clinic catalog.
These go through one separate method, named so that a reviewer cannot miss it, and the rule for every caller is the same: the row's tenant_id is authoritative, and the caller re-binds tenant context from it before mutating anything. The scheduler fires each occurrence inside the tenant the row names, so the handler's own repository calls land in the right partition without knowing they crossed one to get there.
LeadingKeys and the empty key set
The same condition key protects the append-only events table described in four lanes and a hash chain. Event records must never be rewritten, but the hash-chain head is a pointer the append transaction has to move, so the deny on update and delete is scoped by key prefix: it applies to the record prefix and leaves the head writable.
The subtle part is the set operator. A multi-valued condition written with ForAllValues evaluates to true when the key set is empty, so an operation that presents no key at all, such as a PartiQL statement or a batch write, is still denied. That is the same absent-key behavior that makes LeadingKeys useless against a scan, turned around to fail safe. Reading the operator the other way, with ForAnyValue, would have let a keyless mutation through, which is why the policy and the scan lint were reviewed together.
What this lets EverBowl™ do
EverBowl™ learns one household's dogs over months, and every household is its own tenant, so a family's record is its own partition. A meal, an alert or a daily summary written for one dog cannot be read under another family's token, and the Hoomanely app reads the household's record through the same floor an operator does. A device serial is the one global row, because the question it answers is which household owns the bowl, and it is written only through the floor's global claim path.
The honest limit is the index filter. It is enforced by code and review, not by the database or by IAM, so an index read that omits it would return rows from another household and nothing in the infrastructure would refuse it. We treat every new index as a hole until its read passes the filter. EverBowl does not diagnose; the value is the record, and the record is only worth keeping if it is only ever yours.
Conclusion
If you isolate tenants on DynamoDB, put the tenant at the front of every partition key and make the repository the only way to the table. Add the IAM condition so AWS refuses what the code gets wrong. Treat every GSI as a hole: either scope-qualify its partition value or filter every read by the row's tenant, and stamp the index attributes inside the write. Forbid Scan with a lint, because the IAM condition cannot. More on how the backend is built lives in the Software category.
FAQs
How do you isolate tenants in DynamoDB?
Lead every partition key with the tenant id and route all access through one repository that builds those keys from a context bound at authentication. Add a condition expression on mutations so a wrong-tenant write is refused by the database, and an IAM LeadingKeys condition so a wrong-tenant key is refused before the table.
What does dynamodb:LeadingKeys do?
It is an IAM condition key that constrains the partition key values an operation may touch, so a role can be limited to items whose partition key starts with a given prefix. It covers gets, puts, queries and transactions that present keys. It does not constrain a scan, because a scan presents no key.
Does a GSI respect the tenant partition?
No. A global secondary index has its own partition key, and unless you put the tenant into that key, one index partition holds rows from every tenant. Either make the index partition value scope-qualified or filter each result by the row's stored tenant id on every read.
Why is Scan forbidden in a multi-tenant table?
A scan reads the whole table and filters afterwards, so it sees every tenant's rows and is the one operation an IAM key condition cannot limit. It is also the slowest and most expensive read. We fail the build on any scan call in a module and offer no scan method on the repository.
How do system jobs read across tenants safely?
Through one explicit cross-tenant index method whose partition is a work list, such as occurrences due in an hour. The caller treats each row's tenant id as authoritative and re-binds the tenant context from it before mutating, so the row's handler runs inside its own partition.
References
- AWS, Using IAM policy conditions for fine-grained access control in DynamoDB (
dynamodb:LeadingKeys) - AWS, Multivalued context keys: ForAllValues and ForAnyValue
- AWS, Using global secondary indexes in DynamoDB
- AWS, SaaS Lens: tenant isolation
- PostgreSQL, Row security policies