Skip to main content

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:AssumeRole permission
  • 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 IMemoryCache or similar caching mechanism
  • Set cache expiration 5 minutes before actual credential expiration
  • Consider using AssumeRoleAWSCredentials for automatic refresh