Multi-Tenant IoT Platform Architecture: Isolation, Scaling, and Data Partitioning
Building an IoT platform that serves a single customer is engineering. Building one that serves 50 customers simultaneously — each with their own devices, users, data, and SLAs — is a fundamentally different problem.
Multi-tenant IoT platforms fail in predictable ways: Tenant A's rogue device floods the message broker and degrades Tenant B's latency. A misconfigured IoT policy lets Tenant A subscribe to Tenant B's device topics. A poorly designed database schema lets an application bug expose cross-tenant data. A flat pricing model means your largest tenant costs ten times what they pay.
This guide covers the architectural decisions that prevent all of these failure modes.
Isolation Strategy: Silo, Pool, or Bridge
The first decision is how much infrastructure to share between tenants:
| Model | Isolation | Cost efficiency | Complexity | |---|---|---|---| | Silo | Full — separate AWS account per tenant | Lowest | Highest | | Pool | Shared infrastructure, logical separation | Highest | Medium | | Bridge | Shared control plane, isolated data plane | High | High |
Silo model (separate AWS account per tenant): Maximum isolation. A tenant's devices cannot interfere with another tenant even at the infrastructure level. Required for enterprise customers with strict compliance requirements (HIPAA, FedRAMP). Expensive to operate — 50 tenants means 50 AWS accounts to manage, monitor, and update.
Pool model (shared everything, logical separation): Most cost-efficient. All tenants share the same IoT Core endpoint, the same Lambda functions, the same database clusters. Isolation is enforced entirely by software: MQTT topic ACLs, API authentication, database row-level security. Suitable for SMB SaaS products.
Bridge model (our recommendation for most platforms): Shared control plane (API gateway, tenant management, billing), isolated data plane per tenant or per tenant tier. Enterprise tenants get dedicated IoT Core endpoints; SMB tenants share. Scale the data plane independently from the control plane.
MQTT Topic Namespacing
In a pooled or bridge deployment, every MQTT topic must be namespaced by tenant. A flat topic like devices/sensor01/telemetry becomes t/{tenantId}/devices/{deviceId}/telemetry.
// Topic conventions for multi-tenant
const Topics = {
telemetry: (tenantId: string, deviceId: string) =>
t/${tenantId}/devices/${deviceId}/telemetry, heartbeat: (tenantId: string, deviceId: string) =>
t/${tenantId}/devices/${deviceId}/heartbeat,
command: (tenantId: string, deviceId: string) =>
t/${tenantId}/devices/${deviceId}/cmd,
// Wildcard for subscribing to all devices in a tenant (backend only)
tenantWildcard: (tenantId: string) =>
t/${tenantId}/devices/+/telemetry,
}
IoT policies enforce this at the broker level — not at the application level. Application-level enforcement alone is not sufficient; a compromised device or a code bug could bypass it.
AWS IoT Policy Templates with Tenant Variables
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "arn:aws:iot:us-east-1:123456789:client/${iot:Connection.Thing.ThingName}"
},
{
"Effect": "Allow",
"Action": "iot:Publish",
"Resource": [
"arn:aws:iot:us-east-1:123456789:topic/t/${iot:Thing.Attributes[tenantId]}/devices/${iot:Connection.Thing.ThingName}/*"
]
},
{
"Effect": "Allow",
"Action": "iot:Subscribe",
"Resource": [
"arn:aws:iot:us-east-1:123456789:topicfilter/t/${iot:Thing.Attributes[tenantId]}/devices/${iot:Connection.Thing.ThingName}/cmd"
]
},
{
"Effect": "Deny",
"Action": "*",
"Resource": "arn:aws:iot:us-east-1:123456789:topic/t/*",
"Condition": {
"StringNotEquals": {
"iot:Thing.Attributes[tenantId]": "${iot:Thing.Attributes[tenantId]}"
}
}
}
]
}
The ${iot:Thing.Attributes[tenantId]} substitution variable is evaluated at runtime against the connecting device's Thing attributes. A device registered under Tenant A literally cannot publish to Tenant B's topics — the broker rejects the attempt before your application code ever sees it.
Database Partitioning for Multi-Tenancy
DynamoDB: Tenant Prefix Pattern
// All tenant data prefixed by tenantId
const DeviceTable = {
put: async (tenantId: string, device: Device) => {
await db.send(new PutItemCommand({
TableName: 'MultiTenantDevices',
Item: marshall({
pk: TENANT#${tenantId}#DEVICE#${device.id},
sk: 'METADATA',
...device,
tenantId, // always store tenantId on every item
}),
}))
}, query: async (tenantId: string) => {
return db.send(new QueryCommand({
TableName: 'MultiTenantDevices',
KeyConditionExpression: 'begins_with(pk, :prefix)',
ExpressionAttributeValues: marshall({
':prefix': TENANT#${tenantId}#DEVICE#,
}),
}))
},
}
Every query scopes to the tenant's key prefix — cross-tenant reads are impossible by key design, not just by application logic.
For large tenants (>100k devices), consider dedicated DynamoDB tables per tenant using the bridge model. DynamoDB table-level isolation also simplifies GDPR right-to-deletion compliance — drop the table to purge all tenant data.
API Gateway Multi-Tenancy
Every API call must carry a tenant context. Resolve tenant identity at the gateway, not in each microservice.
// Lambda authorizer: resolve tenant from JWT, attach to context
export const authorizer = async (event: APIGatewayAuthorizerEvent) => {
const token = event.authorizationToken.replace('Bearer ', '') const payload = verifyJWT(token, process.env.JWT_SECRET!)
const tenant = await getTenantById(payload.tenantId)
if (!tenant || tenant.status !== 'active') {
throw new Error('Unauthorized')
}
return {
principalId: payload.sub,
policyDocument: allowPolicy(event.methodArn),
context: {
tenantId: tenant.id,
tenantTier: tenant.tier, // 'starter' | 'professional' | 'enterprise'
deviceLimit: tenant.deviceLimit,
userId: payload.sub,
},
}
}
Every downstream Lambda receives tenantId via event.requestContext.authorizer.tenantId — it never trusts tenant identity from the request body or query params.
Per-Tenant Billing and Rate Limiting
Track resource usage per tenant for billing and to prevent noisy-neighbor problems:
// Lambda: record usage metrics per tenant
async function recordUsage(tenantId: string, metric: string, value: number) {
const month = new Date().toISOString().slice(0, 7) // "2024-06" await db.send(new UpdateItemCommand({
TableName: 'TenantUsage',
Key: marshall({ pk: TENANT#${tenantId}, sk: USAGE#${month} }),
UpdateExpression: 'ADD #metric :val',
ExpressionAttributeNames: { '#metric': metric },
ExpressionAttributeValues: marshall({ ':val': value }),
}))
}
// In your telemetry processor:
await recordUsage(tenantId, 'messagesReceived', batch.readings.length)
await recordUsage(tenantId, 'bytesIngested', payloadBytes)
Per-tenant rate limiting at the IoT Rule level is not natively supported, so implement it in your processing Lambda: check a Redis/DynamoDB counter before processing, return early if the tenant has exceeded their plan's message rate.
Tenant Onboarding Automation
Manual tenant onboarding at scale becomes a bottleneck. Automate it with a service that provisions all required resources:
async function onboardTenant(tenantId: string, plan: TenantPlan) {
// 1. Create IoT Thing Group for the tenant
await iot.createThingGroup({ thingGroupName: tenant-${tenantId} }) // 2. Create tenant-scoped IoT policy from template
await iot.createPolicy({
policyName: TenantPolicy-${tenantId},
policyDocument: renderPolicyTemplate(tenantId),
})
// 3. Create DynamoDB table (enterprise) or record tenant prefix (starter)
if (plan === 'enterprise') {
await createDedicatedTable(tenantId)
}
// 4. Create Cognito user pool (or user pool group for pooled model)
await cognito.createGroup({
UserPoolId: USER_POOL_ID,
GroupName: tenant-${tenantId},
})
// 5. Record tenant metadata
await recordTenant({ tenantId, plan, createdAt: Date.now() })
}
With this automation, a new enterprise customer is fully provisioned in under 30 seconds.
The Architecture Diagram
[Tenant A Devices] ──MQTT──┐
[Tenant B Devices] ──MQTT──┤──► AWS IoT Core (shared endpoint)
[Tenant C Devices] ──MQTT──┘ │
IoT Rules
│
Lambda (tenant router)
/ │ \
DynamoDB S3 Archive CloudWatch
(prefix isolated) (prefix isolated) (per-tenant namespace)
\ │ /
API Gateway + Authorizer
│
┌────────────┼────────────┐
Tenant A UI Tenant B UI Tenant C UI
The shared IoT Core endpoint is enforced at the policy layer. The Lambda router validates tenant context on every message. The database uses key prefixes for logical isolation. Each tenant's UI authenticates via Cognito and receives a JWT scoped to their tenantId only.
Multi-tenant IoT is complex, but the complexity is manageable when isolation is enforced at multiple layers: broker policy, API gateway authorizer, and database key design.
Need help? [Contact Code Caracal](/contact) — we've shipped these systems for clients across 15+ countries.