A jump host with a public IP and an open SSH port is the most commonly attacked thing in a lot of AWS accounts, and it exists only so somebody can occasionally run a query. Systems Manager forwards a local port through a managed node to any host that node can reach, so the database stays in its private subnet and nothing accepts inbound connections. This covers the exact command, the agent version and permissions it needs, how it works with no NAT gateway at all, and how to drop the stored database password as well.
The bastion is usually the least defensible thing in the account. It has a public IP, an open SSH port, a set of keys that have been copied around for years, and it exists so that two or three people can occasionally run a query against a database that is otherwise perfectly well hidden.
Systems Manager removes the reason for it. Session Manager can forward a port on your laptop through a managed node to any host that node can reach on the network, and the database is one such host. The node has no inbound rules, no public IP and no SSH keys, because the agent on it dials out rather than listening.
What replaces the jump host
You still need one piece of compute inside the VPC. What changes is everything about it. No inbound security group rule, no key pair, no public address, no user accounts, and access controlled by IAM rather than by who has a copy of a private key.
The remote host does not have to be managed by Systems Manager, which is what makes this work for RDS. AWS states that plainly for the port forwarding document, and the only requirement on the RDS side is ordinary network reachability and name resolution from the node.
If you already run ECS, you may not need a new instance at all. Session Manager can target a task directly when ECS Exec is enabled on it, so an existing task can act as the hop.
The prerequisites, in the order they bite
SSM Agent version 3.1.1374.0 or later on the node. That is the minimum for port forwarding to a remote host, and it is higher than the minimum for a plain session. Amazon Linux and recent Ubuntu images ship the agent, but an old AMI will fail here first.
An instance profile with Session Manager permissions. The AmazonSSMManagedInstanceCore managed policy contains everything required.
The Session Manager plugin on your own machine. The AWS CLI cannot run session commands without it, and the error message when it is missing is not obvious.
A security group path from the node to the database. The database security group has to allow the node's security group on the database port. This is the one firewall rule the whole design still needs.
If there is no NAT gateway, add three endpoints
The agent has to reach the Systems Manager service. Where the node sits in a private subnet with no route to the internet, that means interface VPC endpoints.
for SVC in ssm ssmmessages ec2messages; do
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0abc123 \
--vpc-endpoint-type Interface \
--service-name com.amazonaws.eu-central-1.$SVC \
--subnet-ids subnet-0aaa111 subnet-0bbb222 \
--security-group-ids sg-0endpoint \
--private-dns-enabled
done
The security group on those endpoints must allow inbound 443 from the private subnet where the node lives. Skip that and the node simply never registers, with no error anywhere obvious.
From SSM Agent version 3.3.40.0 onwards the agent prefers the ssmmessages endpoint over ec2messages wherever it is available, so a modern fleet leans on the first two. Creating all three costs nothing extra in complexity and keeps older nodes working.
The command
aws ssm start-session \
--target i-1234567890abcdef0 \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["mydb.example.eu-central-1.rds.amazonaws.com"],"portNumber":["5432"],"localPortNumber":["5432"]}'
host is the database endpoint, portNumber is the port on the remote host, and localPortNumber is what opens on your machine. Leave portNumber out and Session Manager defaults to 80, which is almost never what you meant. Windows shells want the parameters in key="value" form rather than JSON, because quoting rules differ.
Leave that running in one terminal and connect in another as if the database were local.
psql -h 127.0.0.1 -p 5432 -U app_user -d appdb
For a task instead of an instance, the target takes a composite form.
aws ssm start-session \
--target ecs:my-cluster_my-container-id_my-container-runtime-id \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["mydb.example.eu-central-1.rds.amazonaws.com"],"portNumber":["5432"],"localPortNumber":["5432"]}'
That path also needs ssmmessages:CreateControlChannel, ssmmessages:CreateDataChannel, ssmmessages:OpenControlChannel and ssmmessages:OpenDataChannel on the task role.
Permissions for the person, not just the machine
Scope ssm:StartSession to specific instances rather than granting it broadly. AWS's own sample policies use the instance ARN as the resource, and pair it with a rule that lets someone end only their own sessions.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ssm:StartSession"],
"Resource": "*",
"Condition": {
"StringLike": { "ssm:resourceTag/Role": ["db-access"] }
}
},
{
"Effect": "Allow",
"Action": ["ssmmessages:OpenDataChannel"],
"Resource": ["arn:aws:ssm:*:*:session/${aws:userid}-*"]
},
{
"Effect": "Allow",
"Action": ["ssm:TerminateSession", "ssm:ResumeSession"],
"Resource": ["arn:aws:ssm:*:*:session/${aws:userid}-*"]
}
]
}
A tag condition scales better than a list of instance IDs, since tagging one new node grants access without anyone editing a policy. Session documents can also be named as resources in the policy, which is how you allow shell sessions and port forwarding to different groups of people.
Drop the stored database password too
Having removed the SSH key, the password in someone's connection string is the next credential worth deleting. RDS supports IAM database authentication for MariaDB, MySQL and PostgreSQL, where the password is a signed token that expires after fifteen minutes.
aws rds modify-db-instance \
--db-instance-identifier mydb \
--enable-iam-database-authentication \
--apply-immediately
export RDSHOST="mydb.example.eu-central-1.rds.amazonaws.com"
export PGPASSWORD="$(aws rds generate-db-auth-token \
--hostname $RDSHOST --port 5432 --region eu-central-1 --username jane_doe)"
Two documented restrictions shape how you use it. The token must be generated against the DB instance endpoint itself, because a custom Route 53 record will not work. And IAM database authentication needs between 300 and 1000 MiB of extra memory on the instance, which is worth knowing before you enable it on a burstable class.
There is a trade-off in the psql connection too. sslmode=verify-full checks the endpoint you connected to against the certificate the database presents, so connecting to 127.0.0.1 through the tunnel and demanding verify-full will fail on the hostname. Map the real endpoint name to 127.0.0.1 in your hosts file if you want both the tunnel and full certificate verification.
Two things this does not give you
Session logging does not cover port forwarding or SSH sessions. AWS is explicit that the data inside the tunnel is encrypted end to end between the CLI and the Session Manager endpoints, and Session Manager is only carrying it. You will see that a session started, from CloudTrail, but not what was run inside it. If you need query-level auditing, that has to come from the database.
The other is that this is a human access path, not an application one. An application inside the VPC should reach the database directly, with credentials from a task or pod role.
If a public bastion is currently the only route into your database and you would rather have it removed than documented, that work sits inside our AWS cloud management service, and the access review around it is part of security and compliance.
Or read how we handle it in AWS Cloud Management.
Related Articles
Eleven npm Packages Compromised in a 53 Minute Attack That Steals Every Credential Your Build Host Can Reach
On August 4, 2026 a worm pushed malicious versions of eleven npm caching packages inside a 53 minute window, harvesting npm tokens, GitHub PATs, AWS credentials, Kubernetes service account tokens and SSH keys. The headline was keyv and its 604 million monthly downloads, but keyv was the safest package on the list: its malicious release was a major version bump that no caret range accepts. The other ten were patch bumps, silently eligible for every dependency range in the ecosystem. That distinction, not the download count, decided who got hit. This is a practical guide to the defenses that actually change the outcome: what your semver range really grants, why npm install and npm ci are not interchangeable, when to disable install scripts and what breaks when you do, and how to check a tree you already have.
CloudHow You Overpay on AWS by Choosing the Wrong Service
Six AWS pairs where the pricier service is sized for a problem you may not have, what drives each bill, and read-only commands to check which side you are on.
CloudMost Teams Running EKS Would Have Been Fine on ECS
Where ECS is enough, where the Kubernetes API genuinely earns EKS, and what the upgrade clock, the add-on stack and the AWS-only wiring cost you either way.