Every public IP on an EC2 instance exists for one of two reasons: the instance serves the internet, or somebody needs to SSH into it. The second reason stopped being valid years ago, and it is still the most common one we see.
The problem
Instances get a public IP so an engineer can reach them over SSH. That single decision drags a chain of exposure behind it: a security group with port 22 open (often to 0.0.0.0/0 "temporarily"), key pairs shared over Slack and never rotated, an internet gateway route on subnets that never needed one, and a bastion host that is itself another public instance with another key pair.
From an audit standpoint, every one of these is a finding. From an attacker's standpoint, every public IP is scanned within minutes of being assigned. And from an operations standpoint, none of it is necessary: AWS Systems Manager Session Manager gives you a shell on any instance with zero inbound ports, zero public IPs, and zero SSH keys.
The biggest advantage deserves to be stated plainly: port 22 is not restricted, it is closed. Not narrowed to an office IP, not hidden behind a bastion: there is no listening port to protect at all, because access happens over the web. The shell opens in a browser tab from the AWS console (or from the CLI), authenticated by the IAM login the engineer already has.
We have applied this in two very different settings, and the experience splits cleanly. In startups and small companies, Session Manager is a pure win: public IPs disappear, key management disappears, and engineers open a shell on any instance on the fly from the console or CLI. In enterprises, the network part is just as easy, but a different question stops the rollout: once everyone enters the VM through the same door, how do you prove who did what inside it? That question, and what to ship to the SIEM, is where most of this article lives.
The finding
Session Manager inverts the connection model. The SSM Agent on the instance opens an outbound HTTPS connection to the Systems Manager service and waits. When you start a session, the service brokers it over that existing outbound channel. Nothing ever connects inbound to the instance, so there is nothing to expose. That inversion is what makes the browser shell possible: the operator's session rides the agent's outbound channel, which is why port 22 can stay closed on every instance in the fleet.
Three things have to be true for this to work, and they are smaller than most teams expect.
1. The minimum instance role
The instance needs an instance profile whose role allows the SSM Agent to talk to the service. The managed policy AmazonSSMManagedInstanceCore is the supported baseline. If you want the actual minimum instead of the managed policy, this is what the agent needs for sessions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ssm:UpdateInstanceInformation",
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
],
"Resource": "*"
}
]
}
Add s3:GetEncryptionConfiguration and s3:PutObject on your logging bucket, or logs:CreateLogStream and logs:PutLogEvents on your logging group, if you enable session logging (you should, see below). Add kms:GenerateDataKey and kms:Decrypt on the session KMS key if you encrypt sessions (you should as well).
Note what is not in this policy: nothing. No inbound rule, no key material, no ec2:*. The instance role is the entire server-side setup.
2. The minimum user policy
On the operator side, scope ssm:StartSession to the instances the person is allowed to reach. Tag-based scoping keeps this manageable:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "ssm:StartSession",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringEquals": { "ssm:resourceTag/Environment": "dev" }
}
},
{
"Effect": "Allow",
"Action": "ssm:StartSession",
"Resource": "arn:aws:ssm:*:*:document/SSM-SessionManagerRunShell"
},
{
"Effect": "Allow",
"Action": [
"ssm:TerminateSession",
"ssm:ResumeSession"
],
"Resource": "arn:aws:ssm:*:*:session/${aws:username}-*"
}
]
}
The session-document statement matters: without pinning the document, a user who can start any session can bypass your logging preferences by choosing a different session document.
3. The network path (the "firewall")
The agent needs outbound HTTPS to three service endpoints: ssm, ssmmessages, and ec2messages. In a private subnet with no NAT, that means three interface VPC endpoints:
com.amazonaws.<region>.ssm
com.amazonaws.<region>.ssmmessages
com.amazonaws.<region>.ec2messages
The security group on the endpoints allows inbound 443 from the instance security groups (or the VPC CIDR). The instance security group needs outbound 443 to the endpoint security group and nothing else. No inbound rules at all. If session logs go to S3 or CloudWatch, add the s3 gateway endpoint and the logs interface endpoint; add kms if sessions are encrypted.
If the subnets already have a NAT gateway, the endpoints are optional (the agent will happily use the regional public endpoints over NAT), but the interface endpoints keep session traffic off the NAT data-processing meter and inside the VPC.
With those three pieces in place, the public IPs, the bastion, port 22, and the key pairs are all deletable.
When to use it, and when an auditor pushes back
Session Manager is the right default for interactive administrative access to EC2. It is not automatically audit-clean. If we were reviewing your account, this is what we would check.
Use it when access is interactive, occasional, and administrative: debugging, incident response, one-off inspection. Every session start is a StartSession event in CloudTrail tied to an IAM principal, which is already better evidence than an SSH login against a shared key.
Turn on session logging before you call it a control. Out of the box, Session Manager records that a session happened, not what happened inside it. Session activity logging to S3 or CloudWatch Logs (with KMS encryption on both the session and the destination) captures the full terminal transcript. Without it, an auditor will correctly note that your privileged access has no content trail.
The identity gap: everyone is ssm-user. This is the finding most teams miss, and in our experience it is the single point where enterprise rollouts stall. Session Manager authenticates the AWS principal, but on the operating system every session lands as the same local account, ssm-user, with sudo rights by default. Your OS logs, auditd trail, and file ownership all say ssm-user did it. If your access policy or your regulator expects OS-level actions to be attributable to a named individual (and most do), the default setup fails that requirement even though AWS knows exactly who started the session.
What the console identity already gives you. Before reaching for workarounds, know what is attributable out of the box. When a user starts a session from the console or CLI, CloudTrail records the StartSession event with their full principal ARN, and the session ID itself is generated with the IAM user name as its prefix. This is why AWS's own example policies can scope ssm:TerminateSession to arn:aws:ssm:*:*:session/${aws:username}-*. Since the CloudWatch log stream for the session transcript is named after the session ID, the transcript of everything typed and returned in the session is already tied to the console identity by name. What AWS does not do is carry that identity into the operating system: there is no mechanism that logs the console IAM user into the box as themselves, and no automatic provisioning of OS accounts from IAM identities. Inside the VM, attribution stops at ssm-user unless you configure Run As.
Workarounds for the OS-level gap:
- Run As. Session Manager preferences can run sessions as a named OS user instead of
ssm-user, taken from theSSMSessionRunAstag on the IAM principal. Three constraints to plan around: it is Linux and macOS only, therootaccount is not supported, and once Run As is on there is no fallback tossm-user. A missing tag or missing OS account means no session. From an audit perspective the no-fallback behavior is a feature: nobody can silently drop back to the shared identity. - Run As works per person even under SSO. The obvious objection in a federated setup (Entra ID or any SAML IdP in front of AWS) is that everyone shares the same role, so a tag on the role would map everyone to the same OS user. The answer is session tags: the IdP passes
SSMSessionRunAsas a SAML attribute (https://aws.amazon.com/SAML/Attributes/AccessControl:SSMSessionRunAs) with a per-user value, and each person's role session carries their own OS user name. With IAM Identity Center, enable Attributes for Access Control and map the attribute to${path:userName}so it flows from the directory automatically. Same permission set, individual OS identity per human. - The OS account still has to exist first. Session Manager verifies the target account before starting the session and refuses the connection if it is missing. It never creates users. That leaves an enterprise with exactly two options: provision matching Linux accounts on every node ahead of time (user data, Ansible, or a State Manager association syncing the user list), or domain-join the instances so the Run As target is a domain user that exists everywhere by definition. The domain-join path is the cleanest where AD is already present, and OS-level attribution then matches the corporate identity, but be precise about what it is: Session Manager checks that the domain account exists and runs the session as it. The mapping from AWS principal to domain user is tag-based trust, not a Kerberos logon, and an auditor should understand it as such. Note for Entra ID shops: Entra ID alone is not a joinable domain in the classic sense; this path needs real Active Directory in the picture (AWS Managed Microsoft AD, or hybrid AD synced through Entra Connect).
- Accept correlation as the control for small teams: session transcripts carry the IAM identity in their name (see above), CloudTrail carries the principal, and OS logs carry
ssm-userplus timestamps. Documenting that correlation procedure is defensible for low-change environments and startups. It is weak for regulated ones, because it degrades exactly when you need it most: concurrent sessions on the same instance.
When OS logs need to reach the SIEM
Session transcripts in S3 are evidence, but they are not detection. Ship OS-level logs to the SIEM when any of these hold:
- Regulated workloads. If the instance is in scope for PCI DSS, ISO 27001 A.8.15/A.8.16, or similar, privileged session activity must be centrally collected and reviewed, not just stored. A transcript bucket nobody reads does not satisfy "monitored".
- Run As is your attribution control. Then
/var/log/secureand the auditd trail are where the named OS user actually appears, and they need to be tamper-evident and off-box. CloudWatch Agent to CloudWatch Logs, then a subscription filter to Kinesis Data Firehose into the SIEM, is the standard path and keeps the instance role additions minimal. - You need alerting on what happens inside sessions, not just that they occurred: sudo to root, package installs, outbound connections initiated from an interactive shell. That is auditd or Sysmon territory, and it only has value if a detection pipeline consumes it.
If none of those apply (dev accounts, no compliance scope, low blast radius), CloudTrail plus encrypted session transcripts is a proportionate control, and the SIEM integration can wait.
Impact
For a startup, the impact is immediate and unambiguous: public IPs deleted, port 22 closed everywhere, key pairs revoked, bastion terminated, and engineers reach any instance on the fly with nothing but their existing IAM login. The whole migration is an instance profile, three VPC endpoints, and one afternoon.
For an enterprise, the network win is identical, but the honest impact statement includes the second phase: Run As backed by provisioned OS or domain accounts, session transcripts encrypted and centralized, and OS logs flowing to the SIEM where compliance scope demands it. That phase is real work. It is still less work than operating a bastion fleet with rotating SSH keys, and unlike the bastion, it ends with every privileged session attributable to a named person at both the AWS layer and the OS layer.
The end state in both cases is the same: zero inbound ports for administrative access, and the answer to "why does this instance have a public IP" is only ever "it serves the internet".
Want us to look for issues like this in your account? We offer a free AWS audit: upstood.com
Top comments (0)