IAM and networking

Users, roles, policies, the instance role a service should use, VPCs, subnets, security groups, and what public actually means.

6 min read☁️ AWS for Backend Engineers

Two systems decide whether a request to an AWS resource succeeds, before any service logic runs: IAM, which decides who may do what, and the VPC, which decides what can reach what. A backend engineer meets both on the first day — an AccessDenied from S3, a timeout to RDS — and both are simpler than their documentation, once the model is clear.

IAM: principals, policies, and the evaluation

A principal is who is asking: a user (a person with a password and keys), a role (an identity a service or a person assumes for a while), or a service. A policy is a JSON document that allows or denies actions on resources, and the evaluation is one rule: everything is denied unless a policy allows it, and an explicit deny wins over any allow.

json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:GetObject", "s3:PutObject"],
    "Resource": "arn:aws:s3:::code10x-uploads/covers/*",
    "Condition": { "StringEquals": { "s3:x-amz-server-side-encryption": "AES256" } }
  }]
}

The ARN names the resource precisely, and * in a Resource is the line a security review will ask about. Condition narrows further — by source VPC, by tag, by time, by whether the request used TLS. Policies attach to principals (identity policies) or to resources (a bucket policy, a queue policy); a request needs an allow from one of them, and for cross-account access, from both.

The consequence for how you read an AccessDenied: it names the action and the resource, and the answer is always one of three — no policy allows it, an explicit deny forbids it, or the principal is not who you think (the wrong role was assumed, the credentials in the environment are a different user's). aws sts get-caller-identity answers the third before you touch a policy.

Roles for services: no keys in the application

A Java service on AWS should never hold an access key. It runs as a role: an EC2 instance profile, an ECS task role, an EKS service account mapped to a role (IRSA or Pod Identity), a Lambda execution role. The runtime hands the SDK temporary credentials for that role, rotated automatically, through the instance metadata service or an environment the platform sets; the AWS SDK's default credential chain finds them with no configuration, which is why S3Client.create() works in production with nothing in application.properties.

The role's policy is the service's permissions, and least privilege is the discipline: the orders service's role can put objects in the uploads bucket and read one Secrets Manager entry, and nothing else. A key in a properties file, by contrast, is long-lived, copied into every environment, in every developer's shell history, and the thing that turns a leaked repository into a leaked account. This repository's rule — never a credential in the repository, production values in an environment file the unit reads — is the on-premises version of the same principle; on AWS the environment file becomes a role.

For a person, the same idea: no long-lived keys on a laptop, but aws sso login or an identity provider that issues short-lived credentials for a role, and MFA on everything that can hold it.

The VPC: your own network

A VPC is a private network in a region, with an address range you choose (10.0.0.0/16), split into subnets, each in one availability zone. A subnet is public if its route table sends 0.0.0.0/0 to an internet gateway, which means an instance with a public IP in it can be reached from the internet; a subnet is private if it does not — its instances reach the internet only outbound, through a NAT gateway in a public subnet, and nothing reaches them from outside.

plaintext
VPC 10.0.0.0/16
├── public  10.0.1.0/24 (AZ a)   ALB, NAT gateway
├── public  10.0.2.0/24 (AZ b)   ALB
├── private 10.0.11.0/24 (AZ a)  the service (ECS tasks / EC2)
├── private 10.0.12.0/24 (AZ b)  the service
├── private 10.0.21.0/24 (AZ a)  RDS
└── private 10.0.22.0/24 (AZ b)  RDS standby

The shape every backend uses: the load balancer in public subnets, the service in private ones, the database in private ones that only the service's subnets can reach. Two of everything across two availability zones is what "multi-AZ" means, and the reason a service with one instance in one subnet is not highly available whatever its uptime has been. A NAT gateway is billed per hour and per gigabyte, and is the line item that surprises teams whose private services pull large images or call S3 heavily; VPC endpoints for S3, DynamoDB, ECR and the rest route that traffic inside AWS's network, which is cheaper, faster and does not need the NAT at all.

Security groups: the firewall on the instance

A security group is a stateful firewall attached to an instance, a task, a load balancer or a database: inbound rules say what may reach it, and the rules can name another security group as the source. That is the feature that makes the layout above expressible:

plaintext
sg-alb      inbound  443 from 0.0.0.0/0
sg-service  inbound  8080 from sg-alb
sg-rds      inbound  5432 from sg-service

The database accepts connections only from things carrying the service's security group, whatever their IP is today — the same idea as Kubernetes labels, applied to the network. Outbound is allowed by default and usually left so. Network ACLs are the stateless, subnet-level second layer; most teams leave them open and do the work in security groups.

When a connection times out — not refused, times out — from the service to RDS, it is this: the security group on the database does not allow the service's group, or the database is in a subnet the service's route table cannot reach. A refused connection is the database not listening; a timeout is the network never delivering the packet. nc -zv db.host 5432 from the service's host is the test, and the VPC's flow logs are the packet-level record when the answer is not obvious.

Public versus private, decided once

The rule to apply to every resource: is there a reason the internet must reach this directly? For the load balancer, yes. For the service, no — it is reached through the load balancer. For the database, never; a database with a public IP is a breach with a delay. For S3, presigned URLs (the file-upload brief in the system-design course) let a client upload or download directly without the bucket being public. The default for a new resource is private, and every exception is a line in a review.

Progress is saved on this device and to your account when signed in.