Home› Blog› Week 26 - Tenant Isolation on DynamoDB: The Cache …
AWS Weekly Lab AWS Terraform

The cache that answered a question it was never asked

One table, two tenants, and an IAM condition that keeps them apart. Then a cache goes in front of it for speed, and the same call that was refused a moment ago returns the other tenant's data — with nothing in the policy changed.

Verified against current vendor documentation on 9 November 2026. Pricing, limits and API behaviour were checked against the official docs on that date. Cloud services change fast — if you are reading this much later, treat the specifics as a starting point and re-check the linked sources.
AWS Platform Engineering Lab · Week 26 of 52

Lab Overview

A building has a locked door, and a guard who checks your badge against the room number before letting you through. It works. Then someone installs a faster side entrance, because the queue at the door was annoying. The side entrance has its own guard, and that guard was told only "staff may enter."

Nobody removed the badge check. It just stopped being on the route people take.

The problem. Multi-tenant systems pool tenants into one DynamoDB table and keep them apart with an IAM condition: dynamodb:LeadingKeys, which restricts a caller to its own partition key. The control is real. But it is evaluated by DynamoDB, on the request DynamoDB receives — and a cache in front of the table changes who receives the request.

The objective. Build the pooled table and the condition, prove the isolation holds, then put a DAX cluster in front for latency and make the identical call again.

The outcome. tenant-alpha asking for tenant-beta's item is refused with AccessDeniedException going direct, and returns tenant-beta's data through DAX. Same function, same role, same table, same item. Two more documented traps in the same policy behaved the same way. Ten checks, ten matches.

DifficultyIntermediate
Cost$0.1202 measured from Cost Explorer — DAX $0.1200, DynamoDB $0.0002
DeploymentTerraform via HCP Terraform with OIDC dynamic credentials
Regionus-east-1 — a DAX cluster can only read tables in its own Region
Full lab guideGitHub — prerequisites, Terraform, commands, validation, cleanup

Cloud Services and Concepts

ServicePurpose hereWhat it is
Amazon DynamoDBOne pooled table holding both tenants, partitioned by tenant_id A managed key-value database with no servers to size. Each item sits under a partition key, and you are billed per read and write rather than per hour.
DynamoDB Accelerator (DAX)The cache added for latency — and the thing that removes the isolation An in-memory cache that speaks the DynamoDB API, so an application can adopt it by changing its client rather than its queries. It runs as a cluster of nodes inside your VPC.
AWS IAMThe isolation itself: a condition on the partition key the caller may touch AWS's permission system. Policies say which actions a principal may take on which resources, and conditions narrow that further using values taken from the request.
AWS LambdaThree functions running identical code, separated only by their role Runs code on demand with no server to manage. Each function assumes an execution role, and that role is the identity AWS evaluates.
Amazon VPCRequired — DAX has no public endpoint, so the functions must live inside one A private network inside your account. Resources placed in it are reachable from inside, not from the internet.
HCP TerraformRemote applies using OIDC, so no AWS keys are stored anywhere HashiCorp's hosted Terraform. It runs plans and applies on its own machines and keeps the state file.

Pool or silo, and why anyone pools

You can give every tenant its own table — a silo — and isolation becomes a resource boundary rather than a policy. The limit is arithmetic: AWS allows 2,500 tables per account per Region, raised to a maximum of 10,000 on request, after which its own guidance is more accounts. A pooled table has no such ceiling, and pays for it by making isolation a property of a policy rather than of the furniture.

A condition that passes when nobody asks

Two of this week's three findings come from one sentence in AWS's documentation: "A ForAllValues condition evaluates to true when the condition key is not present in the request." A Scan carries no partition key. So a policy that grants Scan alongside a LeadingKeys condition reads as though it restricts the caller to one tenant, and returns every tenant. AWS's fix is a companion Null condition requiring the key to be present at all.

Architecture and Workflow

aws AWS Cloud Region: us-east-1 VPC — DAX has no public endpoint, so the function lives here too tenant-alpha scoped to tenant-alpha IAM condition evaluated dynamodb:LeadingKeys + Null 1 AccessDeniedException — refused DAX cluster 3 nodes · 3 AZs 2 role's conditions never evaluated Pooled table partition: tenant_id DAX service role authorises this read: table-wide, with no tenant in it. Same caller. Same table. The condition exists on one path only.
Both paths end at the same table. The IAM condition only exists on one of them — on the other, the DAX cluster's own service role is what authorises the read, and that role has no tenant in it. The cluster carries the official Amazon DynamoDB icon because AWS's icon set draws DAX as a resource of DynamoDB rather than as a service of its own.

Three Lambda functions run byte-identical code. What differs is the policy on each execution role:

  • alpha and beta carry the policy AWS documents — LeadingKeys scoped to their own tenant, a Null companion, Select pinned to SPECIFIC_ATTRIBUTES, and no Scan.
  • audit carries the same policy with both guards removed and Scan added. It is not a straw man: it is what you get by writing the obvious policy and testing it with GetItem.

All three also hold dax:* read permissions on the cluster, because that is what a team adds when read latency becomes a problem. That grant is the whole story.

Prerequisites

RequirementWhyHow to check
Terraform ≥ 1.10cloud {} block and AWS provider v6terraform version
HCP Terraform workspace, CLI-drivenThe Lambda package is built locally and uploaded with the configuration; a VCS run would only have what is committedterraform init
AWS permissions for DynamoDB, DAX, IAM, Lambda, VPCThe apply creates all fiveaws sts get-caller-identity
Python 3.9+ with pipBuilds the package — the DAX client is not in the Lambda runtimepython --version
AWS CLI v2Teardown is verified against the API, not the destroy outputaws --version

Implementation

What was deployed

ResourceHow manyIts job
DynamoDB table1Pooled, partitioned by tenant_id, on-demand billing
DAX cluster nodes3dax.t3.small, one per Availability Zone — three is the smallest fault-tolerant cluster
Lambda functions3alpha, beta, audit — identical code
IAM roles4One per function, plus the DAX cluster's own service role
VPC, subnets, route table1 / 3 / 1DAX is VPC-only, so the functions had to come inside
Gateway VPC endpoint1The direct path to DynamoDB once the functions lost their public route — free, unlike a NAT gateway

Thirty-two resources in all, applied in about twelve minutes, nearly all of it the DAX cluster.

The HCP Terraform run page showing the week-26-dev apply completed.
The apply, run remotely by HCP Terraform using OIDC dynamic credentials — no AWS access keys are stored in the workspace.
The HCP Terraform variables page for week-26-dev, showing TFC_AWS_PROVIDER_AUTH set to true and TFC_AWS_RUN_ROLE_ARN marked sensitive and write-only.
The two variables that make that work. The workspace holds a role to assume, not a key to use — and the role ARN is write-only, so it cannot be read back out of the workspace that uses it.
A terminal showing the DAX cluster available with three dax.t3.small nodes, one in each of us-east-1a, us-east-1b and us-east-1c.
Three nodes, one per Availability Zone. AWS is explicit that three is the minimum for a fault-tolerant cluster; one or two are for development only.
The DynamoDB console DAX Clusters page showing week26-isolation, status Available, node type dax.t3.small, 3 nodes across 3 zones.
The same cluster as the console reports it: available, dax.t3.small, three nodes across three zones, and the dax:// endpoint the functions connect to.

The two policies, side by side

Everything separating the guarded policy from the one that leaks is these two condition blocks:

"Condition": {
  "ForAllValues:StringEquals": {
    "dynamodb:LeadingKeys": ["tenant-alpha"],
    "dynamodb:Attributes":  ["tenant_id","item_id","description","amount"]
  },
  "Null":                 { "dynamodb:LeadingKeys": "false" },
  "StringEqualsIfExists": { "dynamodb:Select": "SPECIFIC_ATTRIBUTES" }
}

Remove the last two lines and add dynamodb:Scan to the actions, and you have the audit role.

The IAM console JSON policy editor showing the tenant-data-access inline policy on the week26-tenant-isolation-alpha role, with the ForAllValues condition, the Null guard on LeadingKeys and Select pinned to SPECIFIC_ATTRIBUTES.
The same condition block as IAM stores it on the alpha role — not a snippet retyped for the article. The account id is redacted; everything else is the live policy.

Validation and Evidence

Ten calls across three principals and two paths. Each case declares what it expects before it runs, and the script exits non-zero if any case disagrees — three of them expect to be allowed something they should not be, and a script that merely printed what happened would read the same whether the finding reproduced or quietly stopped reproducing.

The DynamoDB console item explorer showing a scan of week26-tenant-isolation-pool returning five items, two with partition key tenant-beta and three with tenant-alpha.
Why this is a tenancy question at all: both tenants' items sit in one table, told apart only by the tenant_id partition key. Nothing here separates them but a policy.
TestExpectedActualStatus
alpha reads its own item, directallowedallowed✅
alpha queries its own partition, directallowedallowed✅
alpha reads beta's item, directdeniedAccessDeniedException✅
alpha queries beta's partition, directdeniedAccessDeniedException✅
alpha reads beta's item through DAXallowedreturned tenant-beta's data✅
alpha queries beta's partition through DAXallowedreturned tenant-beta's data✅
alpha runs Scan, directdeniedAccessDeniedException✅
audit runs Scan, directallowedboth tenants returned✅
alpha reads with no projectiondeniedAccessDeniedException✅
audit reads with no projectionallowedreturned secret_note✅
A terminal showing all ten isolation cases, each with its expected and actual outcome, and the summary line 10/10 cases behaved as expected.
The two rows that matter sit three lines apart: cross-tenant-direct denied, cross-tenant-dax allowed and returning tenants=tenant-beta.

secret_note is an attribute no policy's allow-list contains. Its appearance in a response is the finding showing itself, rather than a claim about what a policy says.

Security and Cost

What this means for a platform team

The sentence worth taking away: if tenant isolation rests on an IAM condition, putting a cache in front of the table is a security change, not a performance change. It will arrive as a latency ticket, be reviewed by whoever owns performance, and will not mention tenancy.

AWS says so plainly — users "inherit the permissions of the DAX cluster's IAM policy when they access that cluster… No other permissions are recognized", and the use of DAX "can subvert those policies." The documented mitigation is one cluster per isolation boundary, each with its own scoped policy, which prices isolation by the node-hour.

Two smaller controls, both worth copying: add a Null condition beside every LeadingKeys condition, and pin dynamodb:Select to SPECIFIC_ATTRIBUTES whenever you restrict attributes — otherwise omitting a projection returns everything.

Cost

LineRate (us-east-1)
DAX dax.t3.small$0.04 per node-hour — three nodes, $0.12/hour
DynamoDB on-demand$0.625 per million writes, $0.125 per million reads
Gateway VPC endpointFree — chosen over a NAT gateway at $0.045/hour

The cluster existed for about fifty minutes. DAX bills per node-hour or partial hour, so fifty minutes is three node-hours, and no deploy-test-destroy cycle of this lab can cost less however fast you are.

Measured: $0.1202. Cost Explorer for 8 November reports $0.1200 against DynamoDB Accelerator and $0.0002 against DynamoDB — the five seeded items and a few hundred reads. The estimate above and the bill agree here, which is worth saying only because they often do not: a rate card tells you the price of a thing, not how many of them a lab turns out to use.

Left running, this is the first week in a while with a meter rather than a bill of cents: $2.88 a day, idle or not.

Troubleshooting and Lessons

Problem. The apply failed with SubnetNotAllowedFault: Subnet … is not in a supported availability zone.
Root cause. This account has a Local Zone enabled (us-east-1-dfw-1a, Dallas), and aws_availability_zones returns names sorted alphabetically. us-east-1-dfw-1a sorts before us-east-1a, because - precedes a. Taking "the first three AZs" put a Local Zone at index 0.
Resolution. Filter the data source on zone-type = availability-zone, which excludes Local Zones and Wavelength Zones.
Lesson. "The first three availability zones" is a sorting assumption wearing the costume of a requirement.

Problem. Terraform replaced all three Lambda functions on every apply, with no code change.
Root cause. The package hashed differently each build. pip writes console-script wrappers into bin/; built on Windows those are .exe files that embed a path, and each dist-info/RECORD stores a hash of them.
Resolution. Delete bin/, the RECORD files and the __pycache__ left by the import check, and write the zip with fixed member timestamps. The package fell from 4.9 MB to 652 KB as a side effect.
Lesson. A Windows executable had been riding inside a package destined for a Linux runtime, and the only symptom was a needlessly churning plan.

Problem. The teardown script reported success without checking anything.
Root cause. It queried a DAX cluster named week26-tenant-isolation. DAX caps cluster_name at 20 characters, so the cluster is actually week26-isolation. The query asked about a name that never existed and reported "gone" regardless — on the one resource billing by the hour.
Resolution. Query the real name. The false pass was proved first, by running the old query against a cluster that was running at the time and watching it report not-found.
Lesson. A safety check that cannot fail is worse than no check, because it is load-bearing in your confidence and nowhere else.

Cleanup and Key Takeaways

Teardown was confirmed against the API rather than the destroy output — a destroy reports what Terraform removed, not what is left in the account. The DAX cluster, the subnet group, the table, the three functions and the VPC were all checked by name, and Terraform's state was confirmed empty.

A cache moves the authorisation boundary

If isolation is an IAM condition, anything that answers on DynamoDB's behalf must enforce it too — and DAX does not. One cluster per boundary is the documented answer, and it has a price per hour.

A condition that is absent is a condition that passed

ForAllValues evaluates true when the key is missing. Pair every LeadingKeys condition with a Null check, or Scan walks through it.

Attribute limits are on the request, not the answer

Omit the ProjectionExpression and everything comes back regardless of dynamodb:Attributes. Pin Select to SPECIFIC_ATTRIBUTES.

Write the expected result before the run

Three of these ten checks expect a failure of isolation. Declaring each outcome up front is what makes the script able to report that the finding stopped reproducing, instead of narrating whatever happened.

Next week: week 27.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent