Scoped Security with WithClient()
The .WithClient() method enables per-request client customization, supporting scenarios like STS-scoped credentials for multi-tenancy, tenant isolation, and fine-grained access control.
Overview
The .WithClient() method is available on all request builders and allows you to swap the DynamoDB client while preserving all other configuration:
// Query with scoped client
var users = await table.Users.Query(x => x.TenantId == tenantId)
.WithClient(scopedClient)
.ToListAsync();
Key Features:
- Preserves all query configuration (keys, filters, projections)
- Works with all operation types (Get, Put, Query, Update, Delete, Batch, Transactions)
- Enables per-request client customization
- Supports fluent chaining
STS-Scoped Credentials for Multi-Tenancy
Use AWS Security Token Service (STS) to assume roles with tenant-specific permissions, providing IAM-level isolation between tenants.
Basic STS Integration
public class TenantScopedService
{
private readonly UserTable _table;
private readonly IAmazonSecurityTokenService _stsClient;
public TenantScopedService(
UserTable table,
IAmazonSecurityTokenService stsClient)
{
_table = table;
_stsClient = stsClient;
}
public async Task<User?> GetUserAsync(string tenantId, string userId)
{
// Assume role for tenant
var assumeRoleResponse = await _stsClient.AssumeRoleAsync(new AssumeRoleRequest
{
RoleArn = $"arn:aws:iam::123456789012:role/TenantRole-{tenantId}",
RoleSessionName = $"tenant-{tenantId}-session",
DurationSeconds = 3600
});
// Create scoped DynamoDB client with temporary credentials
var scopedClient = new AmazonDynamoDBClient(
assumeRoleResponse.Credentials.AccessKeyId,
assumeRoleResponse.Credentials.SecretAccessKey,
assumeRoleResponse.Credentials.SessionToken);
// Execute with scoped client
return await _table.Users.Get(userId)
.WithClient(scopedClient)
.GetItemAsync();
}
}
Benefits:
- Tenant isolation at the IAM level
- Audit trail per tenant
- Fine-grained permissions per tenant
- Compliance with data residency requirements
Complete Multi-Tenancy Implementation
Service Interface
public interface ITenantScopedDynamoDbService
{
Task<IAmazonDynamoDB> GetTenantClientAsync(string tenantId, ClaimsPrincipal user);
}
Service Implementation with Caching
using Amazon.DynamoDBv2;
using Amazon.SecurityTokenService;
using Amazon.SecurityTokenService.Model;
using Microsoft.Extensions.Caching.Memory;
using System.Security.Claims;
public class TenantScopedDynamoDbService : ITenantScopedDynamoDbService
{
private readonly IAmazonSecurityTokenService _stsClient;
private readonly IMemoryCache _cache;
private readonly ILogger<TenantScopedDynamoDbService> _logger;
public TenantScopedDynamoDbService(
IAmazonSecurityTokenService stsClient,
IMemoryCache cache,
ILogger<TenantScopedDynamoDbService> logger)
{
_stsClient = stsClient;
_cache = cache;
_logger = logger;
}
public async Task<IAmazonDynamoDB> GetTenantClientAsync(string tenantId, ClaimsPrincipal user)
{
// Cache key includes tenant and user for security
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var cacheKey = $"dynamodb-client-{tenantId}-{userId}";
// Check cache first
if (_cache.TryGetValue<IAmazonDynamoDB>(cacheKey, out var cachedClient))
{
return cachedClient!;
}
// Assume tenant role
var roleArn = $"arn:aws:iam::123456789012:role/TenantRole-{tenantId}";
var sessionName = $"tenant-{tenantId}-user-{userId}";
_logger.LogInformation("Assuming role {RoleArn} for tenant {TenantId}", roleArn, tenantId);
var assumeRoleResponse = await _stsClient.AssumeRoleAsync(new AssumeRoleRequest
{
RoleArn = roleArn,
RoleSessionName = sessionName,
DurationSeconds = 3600,
Tags = new List<Tag>
{
new() { Key = "TenantId", Value = tenantId },
new() { Key = "UserId", Value = userId ?? "unknown" }
}
});
// Create scoped client
var credentials = assumeRoleResponse.Credentials;
var scopedClient = new AmazonDynamoDBClient(
credentials.AccessKeyId,
credentials.SecretAccessKey,
credentials.SessionToken);
// Cache until credentials expire (with 5 minute buffer)
var expirationTime = credentials.Expiration.AddMinutes(-5);
_cache.Set(cacheKey, scopedClient, expirationTime);
_logger.LogInformation("Created scoped client for tenant {TenantId}, expires at {Expiration}",
tenantId, expirationTime);
return scopedClient;
}
}
Repository Using Scoped Client
public class UserRepository
{
private readonly UserTable _table;
private readonly ITenantScopedDynamoDbService _scopedService;
public UserRepository(
UserTable table,
ITenantScopedDynamoDbService scopedService)
{
_table = table;
_scopedService = scopedService;
}
public async Task<User?> GetUserAsync(string tenantId, string userId, ClaimsPrincipal user)
{
var scopedClient = await _scopedService.GetTenantClientAsync(tenantId, user);
return await _table.Users.Get(userId)
.WithClient(scopedClient)
.GetItemAsync();
}
public async Task<List<User>> QueryUsersByStatusAsync(
string tenantId,
string status,
ClaimsPrincipal user)
{
var scopedClient = await _scopedService.GetTenantClientAsync(tenantId, user);
return await _table.StatusIndex.Query(x => x.Status == status)
.WithClient(scopedClient)
.ToListAsync();
}
public async Task CreateUserAsync(string tenantId, User newUser, ClaimsPrincipal currentUser)
{
var scopedClient = await _scopedService.GetTenantClientAsync(tenantId, currentUser);
await _table.Users.Put(newUser)
.Where(x => x.UserId.AttributeNotExists())
.WithClient(scopedClient)
.PutAsync();
}
}
Controller Using Repository
[ApiController]
[Route("api/tenants/{tenantId}/users")]
public class UsersController : ControllerBase
{
private readonly UserRepository _userRepository;
public UsersController(UserRepository userRepository)
{
_userRepository = userRepository;
}
[HttpGet("{userId}")]
public async Task<ActionResult<User>> GetUser(string tenantId, string userId)
{
var user = await _userRepository.GetUserAsync(tenantId, userId, User);
if (user == null)
return NotFound();
return Ok(user);
}
[HttpGet]
public async Task<ActionResult<List<User>>> GetActiveUsers(string tenantId)
{
var users = await _userRepository.QueryUsersByStatusAsync(tenantId, "active", User);
return Ok(users);
}
[HttpPost]
public async Task<ActionResult<User>> CreateUser(string tenantId, [FromBody] User user)
{
await _userRepository.CreateUserAsync(tenantId, user, User);
return CreatedAtAction(nameof(GetUser), new { tenantId, userId = user.UserId }, user);
}
}
Dependency Injection Setup
// Program.cs
services.AddSingleton<IAmazonSecurityTokenService, AmazonSecurityTokenServiceClient>();
services.AddMemoryCache();
services.AddScoped<ITenantScopedDynamoDbService, TenantScopedDynamoDbService>();
// Register default DynamoDB client for table definition
services.AddSingleton<IAmazonDynamoDB, AmazonDynamoDBClient>();
services.AddSingleton(sp =>
{
var client = sp.GetRequiredService<IAmazonDynamoDB>();
return new UserTable(client, "users");
});
services.AddScoped<UserRepository>();
Using WithClient() in Operations
Get Operations
// Single item get
var user = await table.Users.Get("user123")
.WithClient(scopedClient)
.GetItemAsync();
// Batch get
var batchResponse = await DynamoDbBatch.Get
.Add(table.Users.Get("user1"))
.Add(table.Users.Get("user2"))
.ExecuteAsync(scopedClient);
Put Operations
var user = new User
{
UserId = "user123",
Email = "john@example.com",
Name = "John Doe"
};
// Simple put
await table.Users.Put(user)
.WithClient(scopedClient)
.PutAsync();
// Conditional put - only if item doesn't exist
await table.Users.Put(user)
.Where(x => x.UserId.AttributeNotExists())
.WithClient(scopedClient)
.PutAsync();
Query Operations
// Basic query with lambda
var users = await table.Users.Query(x => x.TenantId == "tenant123")
.WithClient(scopedClient)
.ToListAsync();
// Query with filter
var activeUsers = await table.Users.Query(x => x.TenantId == "tenant123")
.WithFilter(x => x.Status == "active")
.WithClient(scopedClient)
.ToListAsync();
// GSI query
var usersByEmail = await table.EmailIndex.Query(x => x.Email == "john@example.com")
.WithClient(scopedClient)
.ToListAsync();
Update Operations
// Update with lambda expression
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { Name = "Jane Doe", UpdatedAt = DateTime.UtcNow })
.WithClient(scopedClient)
.UpdateAsync();
// Conditional update
await table.Users.Update("user123")
.Set(x => new UserUpdateModel { Status = "inactive" })
.Where(x => x.Status == "active")
.WithClient(scopedClient)
.UpdateAsync();
Delete Operations
// Simple delete
await table.Users.Delete("user123")
.WithClient(scopedClient)
.DeleteAsync();
// Conditional delete
await table.Users.Delete("user123")
.Where(x => x.Status == "inactive")
.WithClient(scopedClient)
.DeleteAsync();
Transaction Operations
// Write transaction with scoped client
await DynamoDbTransactions.Write
.Add(table.Users.Put(user1))
.Add(table.Users.Update("user2")
.Set(x => new UserUpdateModel { Status = "active" }))
.ExecuteAsync(scopedClient);
Security Best Practices
1. Principle of Least Privilege
Grant only necessary permissions to assumed roles. Use IAM policy conditions with leading key constraints to ensure tenant isolation at the database level:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:123456789012:table/users",
"arn:aws:dynamodb:us-east-1:123456789012:table/users/index/*"
],
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["TENANT#${aws:PrincipalTag/TenantId}"]
}
}
}
]
}
This policy ensures that:
- The assumed role can only access items where the partition key starts with the tenant's ID
- Session tags (set during AssumeRole) are used to dynamically scope access
- Even if application code has bugs, IAM prevents cross-tenant data access
2. Session Tags for Audit
Use session tags to track operations and create an audit trail:
var assumeRoleResponse = await _stsClient.AssumeRoleAsync(new AssumeRoleRequest
{
RoleArn = roleArn,
RoleSessionName = sessionName,
Tags = new List<Tag>
{
new() { Key = "TenantId", Value = tenantId },
new() { Key = "UserId", Value = userId },
new() { Key = "Environment", Value = "production" }
}
});
Session tags provide:
- Traceability of all operations back to specific tenants and users
- Integration with CloudTrail for compliance auditing
- Dynamic IAM policy conditions using
${aws:PrincipalTag/TagName}
3. External ID for Cross-Account Access
Use external IDs to prevent the confused deputy problem when assuming roles in other AWS accounts:
var assumeRoleResponse = await _stsClient.AssumeRoleAsync(new AssumeRoleRequest
{
RoleArn = "arn:aws:iam::987654321098:role/CrossAccountRole",
RoleSessionName = "cross-account-session",
ExternalId = "unique-external-id-12345",
DurationSeconds = 3600
});
The target account's trust policy should require the external ID:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/ApplicationRole"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "unique-external-id-12345"
}
}
}
]
}
4. Validate Tenant Access
Always validate that the user has access to the requested tenant before assuming a role:
public async Task<IAmazonDynamoDB> GetTenantClientAsync(string tenantId, ClaimsPrincipal user)
{
// Validate user has access to tenant
var userTenants = user.FindAll("tenant").Select(c => c.Value).ToList();
if (!userTenants.Contains(tenantId))
{
throw new UnauthorizedAccessException($"User does not have access to tenant {tenantId}");
}
// Proceed with AssumeRole only after validation
var roleArn = $"arn:aws:iam::123456789012:role/TenantRole-{tenantId}";
var sessionName = $"tenant-{tenantId}-user-{user.FindFirst(ClaimTypes.NameIdentifier)?.Value}";
var assumeRoleResponse = await _stsClient.AssumeRoleAsync(new AssumeRoleRequest
{
RoleArn = roleArn,
RoleSessionName = sessionName,
DurationSeconds = 3600
});
return new AmazonDynamoDBClient(
assumeRoleResponse.Credentials.AccessKeyId,
assumeRoleResponse.Credentials.SecretAccessKey,
assumeRoleResponse.Credentials.SessionToken);
}
This validation ensures:
- Application-level authorization before IAM-level operations
- Defense in depth with multiple security layers
- Clear error messages for unauthorized access attempts
Performance Considerations
Client Reuse
Reusing DynamoDB clients is critical for performance. Creating new clients for every request incurs connection overhead and can lead to resource exhaustion.
✅ Good: Reuse clients
public class OptimizedService
{
private readonly UserTable _table;
private readonly IAmazonDynamoDB _scopedClient;
public OptimizedService(UserTable table, IAmazonDynamoDB scopedClient)
{
_table = table;
_scopedClient = scopedClient;
}
public async Task<User?> GetUserAsync(string userId)
{
return await _table.Users.Get(userId)
.WithClient(_scopedClient)
.GetItemAsync();
}
public async Task<List<User>> QueryUsersAsync(string tenantId)
{
return await _table.Users.Query(x => x.TenantId == tenantId)
.WithClient(_scopedClient)
.ToListAsync();
}
}
❌ Avoid: Creating clients per request
public class InefficientService
{
private readonly UserTable _table;
public async Task<User?> GetUserAsync(string userId)
{
// Bad: Creates new client for every request
var client = new AmazonDynamoDBClient();
return await _table.Users.Get(userId)
.WithClient(client)
.GetItemAsync();
}
}
Why client reuse matters:
- Connection pooling is maintained across requests
- Reduces TCP handshake overhead
- Avoids socket exhaustion under high load
- Better memory utilization
Credential Caching
Cache STS-assumed role clients to avoid repeated AssumeRole calls. This reduces latency and STS API costs.
public class CachedScopedClientService
{
private readonly IMemoryCache _cache;
private readonly IAmazonSecurityTokenService _stsClient;
public CachedScopedClientService(
IMemoryCache cache,
IAmazonSecurityTokenService stsClient)
{
_cache = cache;
_stsClient = stsClient;
}
public async Task<IAmazonDynamoDB> GetCachedClientAsync(string tenantId)
{
var cacheKey = $"client-{tenantId}";
return await _cache.GetOrCreateAsync(cacheKey, async entry =>
{
var assumeRoleResponse = await _stsClient.AssumeRoleAsync(new AssumeRoleRequest
{
RoleArn = $"arn:aws:iam::123456789012:role/TenantRole-{tenantId}",
RoleSessionName = $"tenant-{tenantId}",
DurationSeconds = 3600
});
// Set cache expiration (5 minutes before credentials expire)
entry.AbsoluteExpiration = assumeRoleResponse.Credentials.Expiration.AddMinutes(-5);
var credentials = assumeRoleResponse.Credentials;
return new AmazonDynamoDBClient(
credentials.AccessKeyId,
credentials.SecretAccessKey,
credentials.SessionToken);
});
}
}
Cache Expiration Handling:
The cache expiration should be set slightly before the actual credential expiration to ensure credentials are refreshed before they become invalid:
// Credentials expire at a specific time
var credentialExpiration = assumeRoleResponse.Credentials.Expiration;
// Cache expires 5 minutes before credentials expire
// This provides a buffer for in-flight requests
entry.AbsoluteExpiration = credentialExpiration.AddMinutes(-5);
// Alternative: Use sliding expiration for frequently accessed tenants
entry.SlidingExpiration = TimeSpan.FromMinutes(30);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(55);
Benefits of credential caching:
- Reduces STS API calls (cost savings)
- Faster response times (no AssumeRole latency)
- Better throughput (fewer external dependencies)
- Graceful handling of credential refresh
Automatic Credential Refresh
The AWS SDK provides AssumeRoleAWSCredentials which automatically refreshes credentials before they expire. This is the simplest approach for long-running applications:
using Amazon.Runtime;
using Amazon.SecurityToken;
// Create credentials that auto-refresh
var stsClient = new AmazonSecurityTokenServiceClient();
var credentials = new AssumeRoleAWSCredentials(
stsClient,
"arn:aws:iam::123456789012:role/TenantRole",
"session-name");
// Client automatically refreshes credentials when needed
var client = new AmazonDynamoDBClient(credentials);
// Use with WithClient() - credentials refresh transparently
var user = await table.Users.Get("user123")
.WithClient(client)
.GetItemAsync();
With Options for Session Tags:
var credentials = new AssumeRoleAWSCredentials(
stsClient,
"arn:aws:iam::123456789012:role/TenantRole",
"session-name",
new AssumeRoleAWSCredentialsOptions
{
DurationSeconds = 3600,
ExternalId = "external-id-12345"
});
var client = new AmazonDynamoDBClient(credentials);
Factory Pattern for Multi-Tenant:
public class AutoRefreshClientFactory
{
private readonly IAmazonSecurityTokenService _stsClient;
private readonly ConcurrentDictionary<string, IAmazonDynamoDB> _clients = new();
public AutoRefreshClientFactory(IAmazonSecurityTokenService stsClient)
{
_stsClient = stsClient;
}
public IAmazonDynamoDB GetClientForTenant(string tenantId)
{
return _clients.GetOrAdd(tenantId, id =>
{
var credentials = new AssumeRoleAWSCredentials(
_stsClient,
$"arn:aws:iam::123456789012:role/TenantRole-{id}",
$"tenant-{id}-session");
return new AmazonDynamoDBClient(credentials);
});
}
}
When to use automatic refresh:
- Long-running services where credentials may expire during operation
- Simpler code without manual cache management
- When you don't need fine-grained control over credential lifecycle
Troubleshooting
Issue: "Access Denied" when assuming role
Cause: Trust relationship not configured correctly
Solution: Ensure the role's trust policy allows your principal to assume it:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/ApplicationRole"
},
"Action": "sts:AssumeRole"
}
]
}
Additional checks:
- Verify the role ARN is correct and the role exists
- Ensure the calling principal has
sts:AssumeRolepermission - Check if there are any explicit deny policies blocking the action
- Verify session tags are allowed in the role's trust policy if using tags
Trust policy with session tags:
If you're using session tags, the trust policy must explicitly allow them:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/ApplicationRole"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"aws:RequestTag/TenantId": "${aws:PrincipalTag/TenantId}"
}
}
},
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/ApplicationRole"
},
"Action": "sts:TagSession"
}
]
}
Issue: Credentials expired during operation
Cause: Long-running operations exceed credential duration
Solution: Implement one of these refresh strategies:
Option 1: Use longer credential duration
var assumeRoleResponse = await _stsClient.AssumeRoleAsync(new AssumeRoleRequest
{
RoleArn = roleArn,
RoleSessionName = sessionName,
DurationSeconds = 7200 // 2 hours instead of default 1 hour
});
Option 2: Proactive credential refresh
Check credential expiration before operations and refresh if needed:
public class CredentialRefreshService
{
private readonly IAmazonSecurityTokenService _stsClient;
private readonly IMemoryCache _cache;
public async Task<IAmazonDynamoDB> GetClientWithRefreshAsync(string tenantId)
{
var cacheKey = $"client-{tenantId}";
var expirationKey = $"expiration-{tenantId}";
// Check if credentials are about to expire (within 5 minutes)
if (_cache.TryGetValue<DateTime>(expirationKey, out var expiration))
{
if (DateTime.UtcNow > expiration.AddMinutes(-5))
{
// Remove cached client to force refresh
_cache.Remove(cacheKey);
_cache.Remove(expirationKey);
}
}
return await _cache.GetOrCreateAsync(cacheKey, async entry =>
{
var assumeRoleResponse = await _stsClient.AssumeRoleAsync(new AssumeRoleRequest
{
RoleArn = $"arn:aws:iam::123456789012:role/TenantRole-{tenantId}",
RoleSessionName = $"tenant-{tenantId}",
DurationSeconds = 3600
});
// Store expiration time
_cache.Set(expirationKey, assumeRoleResponse.Credentials.Expiration);
// Set cache expiration
entry.AbsoluteExpiration = assumeRoleResponse.Credentials.Expiration.AddMinutes(-5);
var credentials = assumeRoleResponse.Credentials;
return new AmazonDynamoDBClient(
credentials.AccessKeyId,
credentials.SecretAccessKey,
credentials.SessionToken);
});
}
}
Option 3: Use AssumeRoleAWSCredentials for automatic refresh
The AWS SDK provides built-in automatic credential refresh:
using Amazon.Runtime;
using Amazon.SecurityToken;
// Create credentials that auto-refresh before expiration
var stsClient = new AmazonSecurityTokenServiceClient();
var credentials = new AssumeRoleAWSCredentials(
stsClient,
"arn:aws:iam::123456789012:role/TenantRole",
"session-name");
// Client automatically refreshes credentials when needed
var client = new AmazonDynamoDBClient(credentials);
// Use with WithClient() - credentials refresh transparently
var user = await table.Users.Get("user123")
.WithClient(client)
.GetItemAsync();
Credential duration limits:
- Minimum: 900 seconds (15 minutes)
- Maximum: 43200 seconds (12 hours) - depends on role configuration
- Recommended: 3600 seconds (1 hour) for most use cases
Issue: High STS API costs
Cause: Creating new clients too frequently without caching
Solution: Implement credential caching as shown in the Performance Considerations section above. Key strategies:
- Cache clients by tenant ID with expiration before credential expiry
- Use
IMemoryCacheor similar caching mechanism - Set cache expiration 5 minutes before actual credential expiration
- Consider using
AssumeRoleAWSCredentialsfor automatic refresh