AWS SSM Session Manager
Shell access with no SSH, no bastion and no open ports. Prerequisites, session logging for audit, and port forwarding to a private database.
Session Manager
Session Manager gives you an interactive shell on an EC2 instance with no SSH port open, no key pairs, and no bastion host. The connection is outbound from the instance to AWS, and you connect through the AWS API.
That removes a lot at once: no port 22 in any security group, no keys to distribute, rotate or revoke when someone leaves, no bastion to patch and pay for, and no public subnet needed for access. Authorisation is IAM, and every session is recorded in CloudTrail against a real identity rather than a shared key.
Connecting
aws ssm start-session --target i-0123456789abcdef0
Starting session with SessionId: alice@example.com-08fe3b1c2d4e5f6a7
sh-5.2$
The CLI needs the Session Manager plugin installed locally, which is a separate download from the AWS CLI itself, SessionManagerPlugin is not found is the error when it is missing.
Prerequisites are the same three as Systems Manager generally: the agent running, AmazonSSMManagedInstanceCore on the instance profile, and outbound access to the ssm, ssmmessages and ec2messages endpoints. ssmmessages is the one that matters here. An instance that shows up in Fleet Manager but fails to start a session is almost always missing that endpoint.
aws ssm describe-instance-information \
--filters Key=InstanceIds,Values=i-0123456789abcdef0 \
--query 'InstanceInformationList[0].PingStatus'
Online means a session should work. Anything else is a prerequisite problem.
Then close port 22
The benefit is not realised until you actually remove SSH access:
aws ec2 revoke-security-group-ingress --group-id sg-0123456789abcdef0 \
--protocol tcp --port 22 --cidr 0.0.0.0/0
An instance reachable through Session Manager and SSH still has the bastion, the keys and the open port to manage. Removing 22 entirely is the point, and it lets instances live in private subnets with no inbound rules at all.
Log every session
Without this you have replaced auditable SSH with unauditable shell access, which is a step backwards:
aws ssm create-document --name SSM-SessionManagerRunShell \
--document-type Session --document-format JSON --content '{
"schemaVersion": "1.0",
"description": "Session preferences",
"sessionType": "Standard_Stream",
"inputs": {
"s3BucketName": "my-session-logs",
"s3EncryptionEnabled": true,
"cloudWatchLogGroupName": "/aws/ssm/sessions",
"cloudWatchEncryptionEnabled": true,
"cloudWatchStreamingEnabled": true,
"kmsKeyId": "alias/session-manager",
"idleSessionTimeout": "20",
"maxSessionDuration": "120",
"runAsEnabled": true,
"runAsDefaultUser": "ssm-user",
"shellProfile": { "linux": "cd /home/ssm-user && bash" }
}
}'
That records every keystroke and every byte of output to S3 and CloudWatch Logs. cloudWatchStreamingEnabled sends output continuously rather than only at session end, so a session that is killed still leaves a record, which is precisely the session you would want to read.
kmsKeyId also enables end-to-end encryption of the session itself, beyond the TLS that is always present.
idleSessionTimeout and maxSessionDuration close forgotten sessions, which is both a security improvement and a way to stop a dangling session holding things open.
Port forwarding is the underrated feature
Session Manager can tunnel a port, which gives you private database access with no bastion and no VPN:
# Forward a port on an instance
aws ssm start-session --target i-0123456789abcdef0 \
--document-name AWS-StartPortForwardingSession \
--parameters '{"portNumber":["8080"],"localPortNumber":["9090"]}'
# Forward to a host the instance can reach. An RDS database, for example
aws ssm start-session --target i-0123456789abcdef0 \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{
"host":["orders.abc123.eu-west-1.rds.amazonaws.com"],
"portNumber":["5432"],
"localPortNumber":["15432"]
}'
psql -h localhost -p 15432 -U app orders
The second form is the one worth knowing. The database stays in a private subnet with no public access, the EC2 instance is only a relay, and your local tooling, psql, a GUI client, a migration script, connects to localhost. No bastion, no VPN client, and the whole thing is authorised by IAM and logged.
SSH over Session Manager
Existing SSH-based tooling can keep working while port 22 stays closed. Add to ~/.ssh/config:
Host i-* mi-*
ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'"
ssh ec2-user@i-0123456789abcdef0
scp file.txt ec2-user@i-0123456789abcdef0:/tmp/
This still uses SSH keys on the instance, so it is a transitional arrangement rather than the end state, but it means Ansible, scp and rsync work without an open port while you migrate.
Restricting who can connect where
An IAM policy scoped by tag is the usual approach:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "ssm:StartSession",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringEquals": { "ssm:resourceTag/Environment": "development" }
}
},
{
"Effect": "Allow",
"Action": ["ssm:TerminateSession", "ssm:ResumeSession"],
"Resource": "arn:aws:ssm:*:*:session/${aws:username}-*"
}
]
}
The second statement is easy to miss: without it a user can start a session and not end their own. The ${aws:username}-* pattern confines them to their own sessions.
You can also restrict which documents may be used, which is how you allow shell access while forbidding port forwarding, or the reverse.
Who is connected right now
aws ssm describe-sessions --state Active \
--query 'Sessions[].[SessionId,Owner,Target,StartDate]' --output table
aws ssm terminate-session --session-id "alice@example.com-08fe3b1c2d4e5f6a7"
Worth an alarm: a session started against a production instance outside change hours is something you want to know about while it is happening.
The ssm-user
Sessions run as ssm-user, created automatically by the agent, with passwordless sudo on Linux by default.
That default deserves attention. Anyone who can start a session has root, so the access control is entirely in IAM, which is fine when IAM is scoped properly and not fine when everyone has ssm:StartSession on *. To remove it:
sudo rm /etc/sudoers.d/ssm-agent-users
Or set runAsDefaultUser in the session document to connect as a specific less-privileged account instead.
Common errors
An error occurred (TargetNotConnected) when calling the StartSession operation:
i-0123456789abcdef0 is not connected.
The agent is not reporting. Check the three prerequisites, and specifically the ssmmessages endpoint.
SessionManagerPlugin is not found. Please refer to SessionManager Documentation
Install the plugin locally; it is separate from the AWS CLI.
An error occurred (AccessDeniedException) when calling the StartSession operation
Missing ssm:StartSession, or a tag condition on the policy that this instance does not satisfy.
Session starts and immediately closes. Usually a broken shellProfile in the session document, or a missing shell on a minimal AMI.
Quick reference
| Task | Command |
|---|---|
| Connect | aws ssm start-session --target <id> |
| Is it reachable? | aws ssm describe-instance-information --filters … |
| Forward a local port | --document-name AWS-StartPortForwardingSession |
| Tunnel to a database | --document-name AWS-StartPortForwardingSessionToRemoteHost |
| SSH through SSM | ProxyCommand with AWS-StartSSHSession |
| Who is connected | aws ssm describe-sessions --state Active |
| End a session | aws ssm terminate-session --session-id <id> |
| Configure logging | aws ssm create-document --name SSM-SessionManagerRunShell |
Key takeaways
- No open port, no keys, no bastion. The agent connects outbound; you connect via the API.
ssmmessagesis the endpoint Session Manager needs: its absence is the usual failure.- Actually close port 22 afterwards, or you have gained nothing.
- Enable session logging with streaming, or you have swapped auditable SSH for unauditable shell.
- Port forwarding to a remote host gives private database access with no bastion or VPN.
AWS-StartSSHSessionkeeps SSH-based tooling working while the port stays closed.- Scope
ssm:StartSessionby tag, and grantTerminateSessionfor the user's own sessions. ssm-userhas passwordless sudo by default: IAM is the only thing standing in front of root.
Check your understanding
Visible in Fleet Manager, sessions fail
An instance in a private subnet appears in Systems Manager with PingStatus Online, but start-session fails with TargetNotConnected. VPC endpoints exist for ssm and ec2messages. What is missing?
Reaching a private database from a laptop
A developer needs to run psql against an RDS instance in a private subnet with no public access. There is no VPN and the team wants no bastion. What works?
Frequently Asked Questions
How is Session Manager more secure than SSH with a bastion?
Three ways. No inbound port is open anywhere. The agent connects outbound, so instances can sit in private subnets with no ingress rules at all. There are no key pairs to distribute, rotate or revoke, because authorisation is IAM and access disappears when the identity does. And every session is attributable to a real principal in CloudTrail, with full keystroke and output logging available, rather than a shared key whose use you can only infer. You also stop patching and paying for a bastion.
Why does start-session fail with TargetNotConnected?
The agent is not reporting to SSM. Check the three prerequisites: the agent is running, the instance profile has AmazonSSMManagedInstanceCore, and there is network access to the SSM endpoints. When the instance appears in Fleet Manager as Online but sessions specifically fail, it is almost always the ssmmessages VPC endpoint, that one carries session traffic, while ssm handles registration, so a partial endpoint set produces exactly this.
How do I log Session Manager sessions?
Create or edit the SSM-SessionManagerRunShell session document with s3BucketName and cloudWatchLogGroupName, and set cloudWatchStreamingEnabled to true so output is written continuously rather than only when the session ends. Otherwise a session that is killed leaves no record, which is the one you would most want. Add a kmsKeyId for end-to-end session encryption, and set idleSessionTimeout and maxSessionDuration to close forgotten sessions.
Can I use Session Manager to reach a private RDS database?
Yes, and it is the feature most worth knowing. Use AWS-StartPortForwardingSessionToRemoteHost with the database hostname and port, plus a local port. The session tunnels through an EC2 instance that can reach the database, and your local psql, GUI client or migration tool connects to localhost. The database needs no public access and there is no bastion or VPN involved, while authorisation stays in IAM and the session is logged.
Can I still use SSH and SCP with Session Manager?
Yes. Add a ProxyCommand entry to ~/.ssh/config using the AWS-StartSSHSession document, and ssh, scp, rsync and Ansible work against instance IDs with port 22 closed to the network. It does still rely on SSH keys on the instance, so treat it as a transition rather than the destination, but it means existing tooling keeps working immediately rather than blocking the removal of your bastion.
Does Session Manager give users root access?
By default, effectively yes. Sessions run as ssm-user, which the agent creates with passwordless sudo on Linux. That makes IAM the entire access control: anyone with ssm:StartSession on an instance has root on it. Scope the permission by tag so people can only reach the environments they should, and either remove /etc/sudoers.d/ssm-agent-users or set runAsDefaultUser in the session document to connect as a less-privileged account where full root is not warranted.