Free .NET Web API Course

Automate RDS Credential Rotation with AWS Secrets Manager for .NET - Zero Downtime Security

Learn how to automatically rotate your RDS database credentials using AWS Secrets Manager and consume them in your ASP.NET Core applications with zero downtime. Part 2 of the Secrets Manager series.

dotnet aws

secrets rds security rotation aspnetcore

17 min read
2.8K views

In my previous article, we explored the basics of AWS Secrets Manager - storing secrets, retrieving them with the AWS SDK, and integrating them into ASP.NET Core’s configuration system. But here’s the thing: storing secrets securely is only half the battle. The real security comes from rotating them regularly.

In this article, we’ll take it to the next level. We’ll set up an RDS PostgreSQL database, store its credentials in Secrets Manager, enable automatic rotation, and build an ASP.NET Core API that seamlessly picks up new credentials without any downtime or restarts.

Trust me, once you see how smooth this is, you’ll wonder why you ever managed database passwords manually!

The full sample code for this article lives at github.com/iammukeshm/aws-secrets-manager-rotation-dotnet — clone it if you want to follow along.

Thank You, AWS! 🚀

This article is sponsored by AWS. Huge thanks for helping me produce more .NET on AWS content!

Sponsored Content

Why Rotate Secrets?

Before we dive into the implementation, let’s understand why secret rotation matters:

1. Compliance Requirements Many compliance frameworks like PCI-DSS, SOC 2, and HIPAA require regular credential rotation. If you’re building applications that handle sensitive data, this isn’t optional.

2. Reduced Blast Radius If a credential gets leaked (and breaches happen), the damage is limited to the rotation window. A password that rotates every 30 days is far less dangerous than one that’s been the same for 3 years.

3. Security Hygiene Hard-coded or long-lived credentials are a ticking time bomb. Automated rotation removes the human factor entirely — no more “we’ll rotate it next sprint” that never happens.

4. Zero Trust Architecture Modern security practices assume breach. Regular rotation ensures that even if credentials are compromised, they become useless quickly.

How Secret Rotation Works

AWS Secrets Manager rotation isn’t magic — it’s a well-orchestrated 4-step process powered by a Lambda function.

How AWS Secrets Manager Rotation Works

The 4-Step Rotation Process

When rotation is triggered (either manually or on schedule), the following steps execute:

Step 1: createSecret A new version of the secret is created with the staging label AWSPENDING. At this point, the new password exists in Secrets Manager but hasn’t been applied to the database yet.

Step 2: setSecret The Lambda function connects to the database using the current credentials and changes the password to the new one stored in AWSPENDING.

Step 3: testSecret The Lambda function attempts to connect to the database using the new credentials. If successful, we know the rotation worked.

Step 4: finishSecret The staging labels are updated:

  • AWSPENDINGAWSCURRENT (new password becomes active)
  • AWSCURRENTAWSPREVIOUS (old password moves to previous)

Your application, using AWSCURRENT, automatically gets the new credentials on the next fetch.

Single-User vs Alternating-User Rotation

AWS offers two rotation strategies:

Single-User Rotation

  • Uses the same database user
  • Simpler to set up
  • Brief moment where credentials are being updated (potential connection failures)

Alternating-User Rotation

  • Uses two database users that alternate
  • While one is being rotated, the other serves traffic
  • Zero downtime — recommended for production

For this tutorial, we’ll use single-user rotation to keep things simple. In production with high-traffic applications, consider alternating-user rotation.

Prerequisites

Here’s what you need to follow along:

  • AWS Account — Free Tier works. Sign up here
  • .NET 10 SDK — We’re using the latest
  • Visual Studio 2026 or VS Code — Note that .NET 10 requires Visual Studio 2026
  • AWS CLI configured — Your machine should be authenticated to AWS. Follow my guide here

Heads up: RDS instances incur costs even on Free Tier after 12 months. We’ll use db.t3.micro which is Free Tier eligible, but make sure to clean up resources at the end!

Step 1: Create an RDS PostgreSQL Instance

Let’s start by creating our database. Log in to the AWS Console and navigate to RDS.

Click Create database and configure the following:

Create RDS Database - Engine Selection

Engine options:

  • Engine type: PostgreSQL
  • Version: PostgreSQL 18.1-R1 (latest)

Templates:

  • Select Free tier (this limits options but keeps costs down)

RDS Settings Configuration

Settings:

  • DB instance identifier: secrets-rotation-demo
  • Master username: postgres
  • Credentials management: Self managed
  • Master password: Choose something strong (we’ll rotate this soon anyway!)

Instance configuration:

  • DB instance class: db.t3.micro (Free Tier eligible)

Storage:

  • Allocated storage: 20 GB (minimum)
  • Disable storage autoscaling for this demo

RDS Connectivity Settings

Connectivity:

  • VPC: Default VPC
  • Public access: Yes (for demo purposes only — never do this in production!)
  • VPC security group: Create new → secrets-rotation-demo-sg

Database authentication:

  • Password authentication

Click Create database and wait a few minutes for the instance to become available.

RDS Instance Available

Once ready, note down the Endpoint — you’ll need this for the connection string.

Configure Security Group

We need to allow inbound traffic to our RDS instance. Navigate to EC2 → Security Groups, find secrets-rotation-demo-sg, and add an inbound rule:

  • Type: PostgreSQL
  • Port: 5432
  • Source: My IP (or 0.0.0.0/0 for demo, but not recommended)

Security Group Inbound Rules

Step 2: Store RDS Credentials in Secrets Manager

Now let’s store our database credentials in Secrets Manager with RDS integration.

Navigate to Secrets Manager and click Store a new secret.

Store New Secret - Select Type

Secret type:

  • Select Credentials for Amazon RDS database

Credentials:

  • Username: postgres
  • Password: The password you set during RDS creation

Database:

  • Select your secrets-rotation-demo instance from the database list.

This is the key difference from storing a regular secret — by linking it to RDS, AWS knows how to rotate the credentials automatically!

Click Next.

Configure Secret Name

Secret name: Production/SecretsRotationDemo/RDS

Following the naming convention from Part 1: Environment/Application/SecretType

Description: PostgreSQL credentials for Secrets Rotation Demo

Click Next.

Step 3: Enable Automatic Rotation

Here’s where the magic happens. On the rotation configuration screen:

Configure Rotation Settings

Automatic rotation: Toggle ON

Rotation schedule: Select the schedule expression builder,

  • Time Unit: Days
  • Days: 30
  • Rotate immediately: Yes (this will test rotation right away)

Rotation function:

  • Select Create a new Lambda function
  • Lambda function name: SecretsRotationDemo-rotation
  • Set the rotation strategy as Single user

What’s happening here? AWS is creating a Lambda function that knows how to connect to PostgreSQL and rotate credentials. This Lambda needs network access to your RDS instance, which AWS handles automatically when you link the secret to RDS.

Click Next, review your configuration, and click Store.

Rotation Lambda Created

AWS will:

  1. Create the secret
  2. Create a Lambda function for rotation
  3. Configure the Lambda’s VPC settings to reach RDS
  4. Trigger the first rotation immediately

Give it a minute, then check your secret. You should see:

Secret Rotation Status

The Rotation status should show the last rotation date and the next scheduled rotation.

Verify Rotation Worked

Click on your secret and go to Retrieve secret value. Notice that the password is different from what you originally set — rotation is working!

Retrieved Secret Value

You’ll also see additional fields that Secrets Manager added automatically:

  • engine: postgres
  • host: Your RDS endpoint
  • port: 5432
  • dbInstanceIdentifier: secrets-rotation-demo
  • username: postgres
  • password: The rotated password

This structure is perfect for building connection strings in .NET.

Step 4: Build the ASP.NET Core API

Now let’s build an API that consumes these rotating credentials. We’ll use EF Core with Npgsql to connect to our RDS PostgreSQL instance.

Create a new ASP.NET Core Web API project:

Terminal window
dotnet new webapi -n SecretsRotation.Api -o SecretsRotation.Api
cd SecretsRotation.Api

Install the required packages:

Terminal window
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package AWSSDK.SecretsManager
dotnet add package AWSSDK.Extensions.NETCore.Setup
dotnet add package Kralizek.Extensions.Configuration.AWSSecretsManager
dotnet add package Scalar.AspNetCore

Note: We’re using Scalar instead of Swagger for API documentation. Scalar is the modern replacement that works great with .NET 10’s built-in OpenAPI support.

Create the Database Context

First, let’s create a simple entity and DbContext. Nothing fancy here — just a basic Product entity to demonstrate database connectivity:

Models/Product.cs
namespace SecretsRotation.Api.Models;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

Now, the DbContext. Notice we’re using the primary constructor syntax (introduced in C# 12) — this keeps things clean and concise:

Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using SecretsRotation.Api.Models;
namespace SecretsRotation.Api.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>().HasData(
new Product { Id = 1, Name = "Laptop", Price = 999.99m },
new Product { Id = 2, Name = "Mouse", Price = 29.99m },
new Product { Id = 3, Name = "Keyboard", Price = 79.99m }
);
}
}

We’re seeding some initial data with HasData() so we have something to query right away. This data gets inserted when you run migrations.

Configure Secrets Manager Integration

Here’s where the real magic happens. We need to:

  1. Register the AWS Secrets Manager client
  2. Fetch credentials differently based on environment (local vs production)
  3. Build the connection string dynamically from the secret’s JSON structure

Let’s break this down piece by piece:

Program.cs
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using SecretsRotation.Api.Data;
using System.Text.Json;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
// Register AWS Secrets Manager
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
builder.Services.AddAWSService<IAmazonSecretsManager>();
// Configure DbContext based on environment
if (builder.Environment.IsDevelopment())
{
// Use local connection string in development
var localConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(localConnectionString));
}
else
{
// Fetch credentials from Secrets Manager in production
var serviceProvider = builder.Services.BuildServiceProvider();
var secretsManager = serviceProvider.GetRequiredService<IAmazonSecretsManager>();
var connectionString = await GetConnectionStringFromSecretAsync(secretsManager);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString));
}
var app = builder.Build();
// Apply migrations on startup
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
app.MapOpenApi();
app.MapScalarApiReference();
app.MapGet("/", () => "Secrets Rotation Demo API - Visit /scalar/v1 for API docs");
app.MapGet("/products", async (AppDbContext db) =>
await db.Products.ToListAsync());
app.MapGet("/products/{id:int}", async (int id, AppDbContext db) =>
await db.Products.FindAsync(id) is { } product
? Results.Ok(product)
: Results.NotFound());
app.MapPost("/products", async (Product product, AppDbContext db) =>
{
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/products/{product.Id}", product);
});
app.MapGet("/health/db", async (AppDbContext db) =>
{
try
{
await db.Database.CanConnectAsync();
return Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow });
}
catch (Exception ex)
{
return Results.Problem($"Database connection failed: {ex.Message}");
}
});
app.Run();
static async Task<string> GetConnectionStringFromSecretAsync(IAmazonSecretsManager secretsManager)
{
var request = new GetSecretValueRequest
{
SecretId = "Production/SecretsRotationDemo/RDS"
};
var response = await secretsManager.GetSecretValueAsync(request);
// Use JsonElement to handle mixed types (port is a number, others are strings)
var secret = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(response.SecretString)!;
// Use NpgsqlConnectionStringBuilder to properly escape special characters in password
var builder = new NpgsqlConnectionStringBuilder
{
Host = secret["host"].ToString(),
Port = secret["port"].GetInt32(),
Database = secret["dbInstanceIdentifier"].ToString(),
Username = secret["username"].ToString(),
Password = secret["password"].ToString()
};
return builder.ConnectionString;
}

Understanding the Code

Let’s break down what’s happening in each section:

AWS SDK Registration

builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
builder.Services.AddAWSService<IAmazonSecretsManager>();

These two lines do a lot of heavy lifting:

  • AddDefaultAWSOptions reads AWS configuration from appsettings.json or environment variables (region, profile, etc.)
  • AddAWSService<IAmazonSecretsManager> registers the Secrets Manager client in the DI container

The SDK automatically handles credential resolution — it checks environment variables, AWS profiles, IAM roles, and more. You don’t need to hardcode any AWS credentials.

Environment-Based Configuration

if (builder.Environment.IsDevelopment())
{
// Use local connection string
var localConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(localConnectionString));
}
else
{
// Fetch from Secrets Manager
var serviceProvider = builder.Services.BuildServiceProvider();
var secretsManager = serviceProvider.GetRequiredService<IAmazonSecretsManager>();
var connectionString = await GetConnectionStringFromSecretAsync(secretsManager);
// ...
}

This pattern is crucial for a smooth development experience:

  • Development: Uses the connection string from appsettings.json — no AWS calls, works offline
  • Production: Fetches credentials from Secrets Manager at startup

Why not always use Secrets Manager? Because during local development, you don’t want to depend on AWS connectivity or pay for API calls. Keep it simple locally, secure in production.

Fetching and Parsing the Secret

static async Task<string> GetConnectionStringFromSecretAsync(IAmazonSecretsManager secretsManager)
{
var request = new GetSecretValueRequest
{
SecretId = "Production/SecretsRotationDemo/RDS"
};
var response = await secretsManager.GetSecretValueAsync(request);
// Use JsonElement to handle mixed types (port is a number, others are strings)
var secret = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(response.SecretString)!;
// Use NpgsqlConnectionStringBuilder to properly escape special characters in password
var builder = new NpgsqlConnectionStringBuilder
{
Host = secret["host"].ToString(),
Port = secret["port"].GetInt32(),
Database = secret["dbInstanceIdentifier"].ToString(),
Username = secret["username"].ToString(),
Password = secret["password"].ToString()
};
return builder.ConnectionString;
}

Here’s what’s happening:

  1. We create a GetSecretValueRequest with the secret’s name (the one we created in Secrets Manager)
  2. GetSecretValueAsync calls AWS and returns the secret value
  3. The SecretString property contains JSON like: {"host":"xxx.rds.amazonaws.com","port":5432,"username":"postgres","password":"rotated-password-here","dbInstanceIdentifier":"secrets-rotation-demo",...}
  4. We deserialize to Dictionary<string, JsonElement> because the port field is a number, not a string
  5. We use dbInstanceIdentifier as the database name — this matches the RDS instance name you configured
  6. We use NpgsqlConnectionStringBuilder to build the connection string — this is critical!

Why JsonElement instead of string? AWS Secrets Manager stores port as a number (e.g., 5432 not "5432"). Using Dictionary<string, string> would throw a JsonException. JsonElement handles mixed types gracefully.

Why NpgsqlConnectionStringBuilder? AWS generates random passwords with special characters like }, |, &, {, #, etc. If you build the connection string manually with string interpolation, these characters break the parsing. NpgsqlConnectionStringBuilder properly escapes everything for you.

Health Check Endpoint

app.MapGet("/health/db", async (AppDbContext db) =>
{
try
{
await db.Database.CanConnectAsync();
return Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow });
}
catch (Exception ex)
{
return Results.Problem($"Database connection failed: {ex.Message}");
}
});

This endpoint is essential for testing rotation. It attempts to connect to the database and returns the result. After rotation, hit this endpoint to verify your app still connects successfully with the new credentials.

Create the Migration

Generate and apply the initial migration:

Terminal window
dotnet ef migrations add InitialCreate

Run the Application

Terminal window
dotnet run

Navigate to https://localhost:5001/scalar/v1 to access the API documentation.

Scalar API Documentation

Test the /health/db endpoint — you should see a healthy response confirming the database connection works.

Health Check Response

Testing with Secrets Manager (Production Mode)

By default, the application runs in Development mode, which uses the local connection string from appsettings.Development.json. To test the Secrets Manager integration, you need to switch to Production mode.

The project includes an https-production launch profile for this purpose. Run the application with:

Terminal window
dotnet run --launch-profile https-production

This sets ASPNETCORE_ENVIRONMENT=Production, which triggers the app to fetch credentials from AWS Secrets Manager instead of using the local connection string. Make sure you have:

  1. Valid AWS credentials configured (via AWS CLI, environment variables, or IAM role)
  2. The secret Production/SecretsRotationDemo/RDS exists in your AWS account
  3. Your RDS instance is accessible from your machine

If everything is configured correctly, the app will connect to your RDS PostgreSQL instance using the credentials stored in Secrets Manager!

Step 5: Handling Credential Rotation at Runtime

The current implementation fetches credentials once at startup. But what happens when credentials rotate while your app is running?

Think about it — your app starts, grabs credentials, opens a connection pool, and serves requests. 30 days later, rotation happens. The old password is now invalid. If your connection pool tries to open new connections with the old password… boom, authentication failures.

For long-running applications, you need to handle credential refresh. Here are two approaches:

Approach 1: Configuration Provider with Polling

If you’re using the Secrets Manager configuration provider approach (from Part 1), you can enable polling to automatically detect changes:

// Add Secrets Manager as configuration source with polling
if (!builder.Environment.IsDevelopment())
{
builder.Configuration.AddSecretsManager(configurator: config =>
{
config.SecretFilter = record => record.Name.Contains("SecretsRotationDemo");
config.PollingInterval = TimeSpan.FromMinutes(5);
});
}

How it works:

  • SecretFilter ensures we only load secrets relevant to our app (saves API calls and costs)
  • PollingInterval tells the provider to check for updates every 5 minutes
  • Combined with IOptionsMonitor<T>, your app automatically sees the new values

The catch: This works great for configuration values, but EF Core’s DbContext is typically registered with a fixed connection string at startup. You’d need additional plumbing to rebuild the connection when credentials change.

For EF Core, a more robust approach is to build a factory that caches credentials and can refresh them when needed:

Services/ResilientDbContextFactory.cs
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using System.Text.Json;
namespace SecretsRotation.Api.Services;
public class ResilientDbContextFactory(
IAmazonSecretsManager secretsManager,
ILogger<ResilientDbContextFactory> logger)
{
private string? _cachedConnectionString;
private DateTime _cacheExpiry = DateTime.MinValue;
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);
public async Task<AppDbContext> CreateDbContextAsync()
{
var connectionString = await GetConnectionStringAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(connectionString)
.Options;
return new AppDbContext(options);
}
private async Task<string> GetConnectionStringAsync(bool forceRefresh = false)
{
if (!forceRefresh && _cachedConnectionString != null && DateTime.UtcNow < _cacheExpiry)
{
return _cachedConnectionString;
}
logger.LogInformation("Fetching fresh credentials from Secrets Manager");
var request = new GetSecretValueRequest
{
SecretId = "Production/SecretsRotationDemo/RDS"
};
var response = await secretsManager.GetSecretValueAsync(request);
// Use JsonElement to handle mixed types (port is a number, others are strings)
var secret = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(response.SecretString)!;
// Use NpgsqlConnectionStringBuilder to properly escape special characters in password
var connBuilder = new NpgsqlConnectionStringBuilder
{
Host = secret["host"].ToString(),
Port = secret["port"].GetInt32(),
Database = secret["dbInstanceIdentifier"].ToString(),
Username = secret["username"].ToString(),
Password = secret["password"].ToString()
};
_cachedConnectionString = connBuilder.ConnectionString;
_cacheExpiry = DateTime.UtcNow.Add(_cacheDuration);
return _cachedConnectionString;
}
public async Task InvalidateCacheAsync()
{
_cacheExpiry = DateTime.MinValue;
await GetConnectionStringAsync(forceRefresh: true);
}
}

Let’s break down what this factory does:

Credential Caching

private string? _cachedConnectionString;
private DateTime _cacheExpiry = DateTime.MinValue;
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);

We cache the connection string for 5 minutes. This is crucial — without caching, every database operation would call Secrets Manager, which is slow and expensive. The 5-minute window balances freshness with performance.

Smart Refresh Logic

if (!forceRefresh && _cachedConnectionString != null && DateTime.UtcNow < _cacheExpiry)
{
return _cachedConnectionString;
}

If the cache is still valid, return it immediately. No AWS call needed. This makes subsequent requests fast.

Cache Invalidation

public async Task InvalidateCacheAsync()
{
_cacheExpiry = DateTime.MinValue;
await GetConnectionStringAsync(forceRefresh: true);
}

When you detect an authentication failure (catch a PostgresException with auth errors), call this method to force a credential refresh. The next request will use the new password.

Why 5 minutes? It’s a reasonable balance. Secrets Manager rotation takes about 30 seconds, so a 5-minute cache means you’ll pick up new credentials within 5 minutes of rotation. For most apps, this is fine. If you need faster recovery, reduce the cache duration (but watch your API costs).

Using the Resilient Factory

To use the ResilientDbContextFactory, register it as a singleton in your Program.cs:

// Register the resilient factory for runtime credential refresh
builder.Services.AddSingleton<ResilientDbContextFactory>();

Then inject and use it in your endpoints or services:

// Endpoint to force credential refresh (useful for testing rotation)
app.MapPost("/admin/refresh-credentials", async (ResilientDbContextFactory factory, CancellationToken ct) =>
{
await factory.InvalidateCacheAsync(ct);
return Results.Ok(new { Message = "Credentials cache invalidated and refreshed", Timestamp = DateTime.UtcNow });
});

For scenarios where you need to create a DbContext on-demand with the latest credentials:

app.MapGet("/products/resilient", async (ResilientDbContextFactory factory, CancellationToken ct) =>
{
await using var db = await factory.CreateDbContextAsync(ct);
return await db.Products.ToListAsync(ct);
});

This approach is particularly useful when:

  • You have long-running background services that need fresh credentials
  • You want to manually trigger a credential refresh after detecting auth failures
  • You need to create DbContext instances outside the normal DI scope

Step 6: Testing the Rotation Flow

Let’s verify that rotation works end-to-end.

Trigger Manual Rotation

In the AWS Console, navigate to your secret and click Rotate secret immediately.

Trigger Manual Rotation

Wait about 30 seconds for the rotation to complete.

Verify Your Application

With your application still running, hit the /health/db endpoint again.

If you’re using the startup-only approach: The old credentials are still cached in the DbContext. Restart the application and it will pick up the new credentials.

If you’re using the resilient factory: The cache will expire within 5 minutes, and the next request will use fresh credentials automatically.

The connection works with zero code changes or deployments!

Best Practices & Gotchas

Rotation Schedule Recommendations

EnvironmentRotation FrequencyNotes
Development90 daysLess aggressive, fewer API calls
Staging30 daysMatch production behavior
Production7-30 daysBalance security vs. operational overhead

Cost Considerations

  • Secrets Manager: $0.40/secret/month + $0.05 per 10,000 API calls
  • Lambda invocations: Minimal (once per rotation)
  • Tip: Cache credentials in your app to minimize GetSecretValue calls

Common Pitfalls

  1. Lambda VPC Configuration: The rotation Lambda must be in the same VPC as RDS, or have proper networking. AWS handles this automatically when you link the secret to RDS.
  2. Security Group Rules: The Lambda’s security group must allow outbound traffic to RDS on port 5432.
  3. Secret Permissions: Your application’s IAM role needs secretsmanager:GetSecretValue permission for the specific secret.
  4. Connection Pool Stale Connections: After rotation, existing connections in the pool may fail. Configure your connection pool to validate connections before use.

Cleanup

Don’t forget to clean up resources to avoid charges!

  1. Delete the Secret: Secrets Manager → Select secret → Actions → Delete secret
  2. Delete RDS Instance: RDS → Databases → Select instance → Actions → Delete
  3. Delete Lambda Function: Lambda → Functions → Delete SecretsRotationDemo-rotation
  4. Delete Security Group: EC2 → Security Groups → Delete secrets-rotation-demo-sg

Summary

In this article, we took AWS Secrets Manager to the next level by implementing automatic credential rotation for RDS databases. Here’s what we covered:

  • Why rotation matters — compliance, security hygiene, and reduced blast radius
  • How rotation works — the 4-step process and version staging
  • Hands-on setup — RDS instance, Secrets Manager, and automatic rotation configuration
  • ASP.NET Core integration — consuming rotating credentials with EF Core
  • Runtime handling — caching strategies and resilient connection factories
  • Best practices — monitoring, cost optimization, and common pitfalls

The beauty of this approach is that once it’s set up, you never think about database passwords again. They rotate automatically, your application picks them up seamlessly, and you sleep better at night knowing your credentials aren’t sitting unchanged for years.

If you found this helpful, share it with your team — and if there’s a topic you’d like me to cover next, drop a comment below!

Happy Coding :)

What's your Feedback?
Do let me know your thoughts around this article.

.NET + AI: Build Smarter, Ship Faster

Join 8,000+ developers learning to leverage AI for faster .NET development, smarter architectures, and real-world productivity gains.

AI + .NET tips
Productivity hacks
100% free
No spam, unsubscribe anytime