Rotación automatizada de contraseñas para Aurora PostgreSQL mediante Secrets Manager y una Lambda personalizada
Introducción
Gestionar las credenciales de las bases de datos es uno de los aspectos más críticos —y, a menudo, más descuidados— de la seguridad en la nube. Las contraseñas estáticas que nunca cambian generan una gran superficie de ataque: una única credencial filtrada puede permitir que un atacante mantenga acceso persistente a los datos de producción.
AWS Secrets Manager aborda este problema gestionando el ciclo de vida completo de las credenciales de las bases de datos: las almacena de forma cifrada, las distribuye a las aplicaciones y las rota automáticamente según una programación definida. Al combinarlo con Amazon Aurora PostgreSQL y una función de Lambda que implementa el protocolo de rotación, todo el proceso puede ejecutarse sin intervención manual.
En este artículo se muestra cómo implementar la rotación automática de contraseñas para usuarios de Aurora PostgreSQL utilizando AWS Secrets Manager y una función Lambda nativa, aprovisionando toda la infraestructura mediante Terraform.
¿Por qué no utilizar la aplicación de SAR?
AWS ofrece una función Lambda de rotación preconfigurada a través de Serverless Application Repository (SecretsManagerPostgreSQLRotationMultiUser). Sin embargo, para implementarla se requieren los permisos: serverlessrepo:GetApplication y serverlessrepo:CreateCloudFormationChangeSet sobre un recurso administrado por AWS. Estos permisos suelen no estar incluidos en los Permission Sets de SSO, incluso para roles con permisos de Administrador, ya que se aplican a un recurso ubicado en otra cuenta de AWS.
En lugar de solicitar permisos de SAR entre cuentas, implementar la función Lambda directamente como un recurso nativo resulta más sencillo y permite tener un control total sobre el código.
Arquitectura
El flujo de rotación funciona de la siguiente manera:
Secrets Manager activa la función Lambda según la programación establecida (cada 90 días en este ejemplo).
La función Lambda ejecuta el protocolo de rotación de 4 pasos.
Cada paso lee o escribe el secreto en la versión identificada como AWSPENDING o AWSCURRENT.
Una vez completado el proceso correctamente, las nuevas credenciales pasan a ser AWSCURRENT y quedan disponibles de inmediato para las aplicaciones.

Requisitos previos:
Un clúster de Amazon Aurora PostgreSQL existente (o RDS PostgreSQL; ver la nota al final).
El clúster debe ser accesible desde dentro de la VPC.
Terraform >= 1.0 y el proveedor de AWS >= 5.0.
Python 3.13 instalado localmente (requerido por terraform-aws-modules/lambda/aws para empaquetar las dependencias).
Los usuarios de la base de datos que se quieran rotar deben existir previamente en la base de datos.
Estructura del proyecto
project/
├── aurora.tf
├── lambda.tf
└── lambdas/
└── aurora-rotation/
├── index.py
└── requirements.txtPaso 1 — Grupo de seguridad para la Lambda
La función Lambda encargada de la rotación necesita acceso saliente a Aurora en el puerto 5432 y a Secrets Manager en el puerto 443:
# aurora.tf
resource "aws_security_group" "rotation_lambda" {
name = "project-environment-rotation-lambda"
description = "Security group for the Aurora password rotation Lambda"
vpc_id = vpc_id
egress {
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"] # adjust to your VPC CIDR
description = "Allow outbound to Aurora"
}
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow outbound to Secrets Manager endpoint"
}
}
# Allow inbound from the Lambda SG into Aurora
resource "aws_security_group_rule" "aurora_from_rotation_lambda" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_group_id = aurora_security_group_id
source_security_group_id = aws_security_group.rotation_lambda.id
description = "Allow PostgreSQL traffic from rotation Lambda"
}Paso 2 — Definir los secretos
Cada usuario de la base de datos tiene su propio secreto. La regla de ciclo de vida ignore_changes evita que Terraform sobrescriba el valor después de que la función Lambda lo haya rotado:
resource "aws_secretsmanager_secret" "dev_user_secret" {
name = "/${local.project}/development/DB_PASSWORD"
description = "Credentials for dev_user on Aurora PostgreSQL"
recovery_window_in_days = 0
})
resource "aws_secretsmanager_secret_version" "dev_user_secret_val" {
secret_id = aws_secretsmanager_secret.dev_user_secret.id
secret_string = jsonencode({
engine = "postgres"
host = module.aurora.cluster_endpoint
port = 5432
dbname = "dev_db"
username = "dev_user"
password = var.DEV_DB_PASSWORD
})
lifecycle {
# Prevent Terraform from overwriting after Lambda rotates
ignore_changes = [secret_string]
}
}Paso 3 — Rol de IAM para la Lambda
# lambda.tf
resource "aws_iam_role" "aurora_rotation_lambda" {
name = "${local.project}-${local.environment}-aurora-rotation"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "aurora_rotation_vpc_execution" {
role = aws_iam_role.aurora_rotation_lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}
resource "aws_iam_policy" "aurora_rotation_secrets" {
name = "${local.project}-${local.environment}-aurora-rotation-secrets"
description = "Allow rotation Lambda to manage Secrets Manager secrets"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecretVersionStage",
"secretsmanager:GetRandomPassword",
]
Resource = "*"
}
]
})
}
resource "aws_iam_role_policy_attachment" "aurora_rotation_secrets" {
role = aws_iam_role.aurora_rotation_lambda.name
policy_arn = aws_iam_policy.aurora_rotation_secrets.arn
}Paso 4 — Implementar la función Lambda de rotación
El módulo terraform-aws-modules/lambda/aws se encarga del empaquetado, incluida la instalación de pg8000 (un driver de PostgreSQL desarrollado completamente en Python, que no requiere compilación nativa):
# lambda.tf (continued)
module "aurora_rotation_lambda" {
source = "terraform-aws-modules/lambda/aws"
version = "8.8.0"
function_name = "${local.project}-${local.environment}-aurora-rotation"
handler = "index.lambda_handler"
runtime = "python3.13"
timeout = 30
create_role = false
lambda_role = aws_iam_role.aurora_rotation_lambda.arn
source_path = "../../../lambdas/aurora-rotation"
# pg8000 is a pure-Python PostgreSQL driver — no native compilation needed
layers = []
environment_variables = {
SECRETS_MANAGER_ENDPOINT = "https://secretsmanager.${data.aws_region.current.name}.amazonaws.com"
}
vpc_subnet_ids = module.vpc.private_subnets
vpc_security_group_ids = [aws_security_group.lambda_rotation_sg.id]
allowed_triggers = {
SecretsManagerDev = {
principal = "secretsmanager.amazonaws.com"
source_arn = aws_secretsmanager_secret.dev_user_secret.arn
}
SecretsManagerQa = {
principal = "secretsmanager.amazonaws.com"
source_arn = aws_secretsmanager_secret.qa_user_secret.arn
}
}
# Avoid adding permissions to a specific version — use $LATEST alias
create_current_version_allowed_triggers = false
cloudwatch_logs_retention_in_days = local.cloudwatch_log_group_retention_in_days
}Nota: El valor de runtime debe coincidir con la versión de Python instalada localmente. El módulo ejecuta pip install utilizando ese intérprete para empaquetar las dependencias. Si tenés python3.11, configurá runtime = "python3.11".
Paso 5 — Configurar la programación de rotación
resource "aws_secretsmanager_secret_rotation" "dev_user_rotation" {
secret_id = aws_secretsmanager_secret.dev_user_secret.id
rotation_lambda_arn = module.aurora_rotation_lambda.lambda_function_arn
rotation_rules {
automatically_after_days = 90
}
}Paso 6 — La función de rotación
Creá lambdas/aurora-rotation/requirements.txt:
pg8000==1.31.2Creá lambdas/aurora-rotation/index.py:
"""
Aurora PostgreSQL single-user secret rotation for AWS Secrets Manager.
The Lambda connects using the secret's own credentials and changes its
own password. No masterarn required.
Rotation steps
--------------
1. createSecret – generate a new random password and stage it as AWSPENDING.
2. setSecret – connect to Aurora as the user and ALTER its own password.
3. testSecret – verify the pending credentials can open a connection.
4. finishSecret – promote AWSPENDING → AWSCURRENT.
"""
import json
import logging
import os
import boto3
import pg8000.native
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
secret_arn = event["SecretId"]
token = event["ClientRequestToken"]
step = event["Step"]
sm = boto3.client(
"secretsmanager",
endpoint_url=os.environ.get("SECRETS_MANAGER_ENDPOINT"),
)
metadata = sm.describe_secret(SecretId=secret_arn)
if not metadata.get("RotationEnabled"):
raise ValueError(f"Rotation is not enabled for secret {secret_arn}")
versions = metadata.get("VersionIdsToStages", {})
if token not in versions:
raise ValueError(f"Token {token} not found in versions of {secret_arn}")
if "AWSCURRENT" in versions[token]:
logger.info("Token is already AWSCURRENT — nothing to do")
return
if "AWSPENDING" not in versions[token]:
raise ValueError(f"Token {token} is not staged as AWSPENDING for {secret_arn}")
if step == "createSecret":
_create_secret(sm, secret_arn, token)
elif step == "setSecret":
_set_secret(sm, secret_arn, token)
elif step == "testSecret":
_test_secret(sm, secret_arn, token)
elif step == "finishSecret":
_finish_secret(sm, secret_arn, token)
else:
raise ValueError(f"Unknown rotation step: {step}")
def _create_secret(sm, arn, token):
"""Stage a new random password as AWSPENDING (idempotent)."""
try:
sm.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING")
logger.info("createSecret: AWSPENDING already exists — skipping")
return
except sm.exceptions.ResourceNotFoundException:
pass
current = _get_secret_dict(sm, arn, stage="AWSCURRENT")
new_password = sm.get_random_password(
PasswordLength=32,
ExcludeCharacters="/@\"'\\",
)["RandomPassword"]
current["password"] = new_password
sm.put_secret_value(
SecretId=arn,
ClientRequestToken=token,
SecretString=json.dumps(current),
VersionStages=["AWSPENDING"],
)
logger.info("createSecret: new AWSPENDING version staged")
def _set_secret(sm, arn, token):
"""Connect as the user itself and ALTER its own password."""
pending = _get_secret_dict(sm, arn, version_id=token, stage="AWSPENDING")
# Idempotency: if pending creds already work, password was already changed
if _can_connect(pending):
logger.info("setSecret: pending credentials already work — skipping ALTER")
return
# Connect using current credentials
current = _get_secret_dict(sm, arn, stage="AWSCURRENT")
conn = _connect(current)
try:
username = pending["username"]
new_password = pending["password"]
# PostgreSQL does not support bind parameters in DDL statements.
# Fetch a properly escaped literal from the server to prevent injection.
escaped = conn.run("SELECT quote_literal(:pwd)", pwd=new_password)[0][0]
conn.run(f'ALTER USER "{username}" WITH PASSWORD {escaped}')
logger.info("setSecret: password updated successfully")
finally:
conn.close()
def _test_secret(sm, arn, token):
"""Verify the AWSPENDING credentials can connect to Aurora."""
pending = _get_secret_dict(sm, arn, version_id=token, stage="AWSPENDING")
conn = _connect(pending)
conn.close()
logger.info("testSecret: connection with AWSPENDING credentials succeeded")
def _finish_secret(sm, arn, token):
"""Promote AWSPENDING to AWSCURRENT."""
metadata = sm.describe_secret(SecretId=arn)
current_version = next(
(v for v, stages in metadata["VersionIdsToStages"].items() if "AWSCURRENT" in stages),
None,
)
if current_version == token:
logger.info("finishSecret: token is already AWSCURRENT — nothing to do")
return
sm.update_secret_version_stage(
SecretId=arn,
VersionStage="AWSCURRENT",
MoveToVersionId=token,
RemoveFromVersionId=current_version,
)
logger.info("finishSecret: AWSCURRENT promoted to token %s", token)
def _get_secret_dict(sm, arn, *, stage, version_id=None):
kwargs = {"SecretId": arn, "VersionStage": stage}
if version_id:
kwargs["VersionId"] = version_id
raw = sm.get_secret_value(**kwargs)["SecretString"]
return json.loads(raw)
def _connect(secret_dict):
return pg8000.native.Connection(
user=secret_dict["username"],
password=secret_dict["password"],
host=secret_dict["host"],
port=int(secret_dict.get("port", 5432)),
database=secret_dict.get("dbname", "postgres"),
ssl_context=True,
timeout=5,
)
def _can_connect(secret_dict):
try:
conn = _connect(secret_dict)
conn.close()
return True
except Exception:
return FalsePaso 7 — Ejemplo de tfvars
DEV_DB_PASSWORD = Example123Nota de seguridad: Nunca hagas commit de initial_password en el repositorio. Pasalo mediante una variable de entorno (TF_VAR_db_users) o utilizá un backend de secretos como AWS SSM Parameter Store.
Paso 8 — Probar la rotación
Forzá una rotación inmediata para validar la configuración:
aws secretsmanager rotate-secret \
--secret-id "/myapp/staging/APP_DB_PASSWORD" \
--region us-east-1Seguí los registros de la función Lambda en tiempo real:
aws logs tail /aws/lambda/myapp-staging-aurora-rotation \
--region us-east-1 \
--followUna rotación exitosa genera cuatro entradas de registro secuenciales:
createSecret: new AWSPENDING version staged
setSecret: password updated successfully
testSecret: connection with AWSPENDING credentials succeeded
finishSecret: AWSCURRENT promoted to token <version-id>Confirmá que la rotación se haya completado:
aws secretsmanager describe-secret \
--secret-id "/myapp/staging/APP_DB_PASSWORD" \
--region us-east-1 \
--query '{LastRotatedDate: LastRotatedDate, RotationEnabled: RotationEnabled}'Conclusión
Esta configuración elimina por completo el uso de contraseñas estáticas para las bases de datos. Una vez implementada, las credenciales se rotan según la programación establecida, sin necesidad de intervención manual. Las aplicaciones siempre obtienen el valor actual desde Secrets Manager al momento de establecer la conexión, por lo que la rotación es transparente para ellas.
Las principales ventajas de utilizar un enfoque basado en una Lambda nativa frente a SAR son:
No requiere permisos entre cuentas.
Se integra naturalmente con los flujos de trabajo existentes de Terraform.
Ofrece visibilidad total sobre la lógica de rotación, lo que facilita la depuración, auditoría y ampliación de la solución.
Nota: Si bien esta implementación utiliza Python, AWS Lambda admite varios otros lenguajes para las funciones de rotación, entre ellos Rust (recomendado por AWS), Node.js (JavaScript/TypeScript), Java, Go, Ruby, C#/.NET y PowerShell.
Referencias

Tomas Köhler
Cloud Engineer



