Defense in Depth: Implementing Organization-Wide IP Restrictions in AWS
On This Page
Defense in Depth: Implementing Organization-Wide IP Restrictions in AWS
When you’re running a personal AWS environment for security research and learning, one question keeps nagging: Who else could be accessing my resources? Even with strong IAM policies and MFA, the attack surface of “anywhere on the internet” feels unnecessarily large.
I decided to lock down my entire AWS organization to only allow API access from my home network and VPN. What started as a simple security group change evolved into a five-phase implementation touching every layer of AWS access control. Here’s what I learned about defense in depth, the decisions I made at each layer, and why the order matters.
The Goal: Restrict All AWS Access to Known IPs
The requirements were straightforward:
- Allow AWS API access only from my home network and VPN
- Maintain GitLab CI/CD deployments (dynamic IPs)
- Keep AWS service integrations working (CloudTrail, Config, Lambda)
- Have emergency access if both networks become unavailable
- Apply organization-wide across all accounts
The challenge: AWS doesn’t have a single “restrict all access to these IPs” switch. You need to layer multiple controls, each with different scopes and limitations.
The Architecture: Five Layers of Control
flowchart TB
subgraph Internet["Internet"]
User["User Request"]
GitLab["GitLab CI/CD"]
Attacker["Unauthorized Access"]
end
subgraph Layer1["Layer 1: Network"]
SG["Security Groups"]
end
subgraph Layer2["Layer 2: Identity"]
IAM["IAM Policies"]
SCP["Service Control Policies"]
end
subgraph Layer3["Layer 3: Emergency"]
BG["Break-Glass User"]
end
subgraph AWS["AWS Resources"]
EC2["EC2 Instances"]
API["AWS APIs"]
S3["S3 Buckets"]
end
User -->|"Approved IP"| SG
User -->|"Approved IP"| IAM
GitLab -->|"Exempted Role"| IAM
Attacker -->|"Blocked"| SG
Attacker -->|"Denied"| SCP
SG -->|"Allow"| EC2
IAM -->|"Allow"| API
SCP -->|"Allow"| API
BG -->|"Emergency Only"| API
API --> S3
style Attacker fill:#ff6b6b,color:#fff
style User fill:#51cf66,color:#fff
style GitLab fill:#fcc419,color:#000
style BG fill:#ff922b,color:#fff
Each layer serves a specific purpose and catches what the others might miss.
Phase 1: Security Groups (Network Layer)
Scope: EC2 instances only Risk Level: Low Rollback: Change variable, re-apply
Security groups were the safest starting point. They only affect network access to EC2 instances—not AWS API calls. If I got the IPs wrong, I’d lose SSH/RDP access to my lab machines but could still fix everything via the AWS Console.
variable "admin_cidr_blocks" {
description = "CIDR blocks allowed to access lab machines"
type = list(string)
default = ["0.0.0.0/0"] # Override in tfvars!
}
resource "aws_security_group" "lab_access" {
name = "security-lab-access"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = var.admin_cidr_blocks
description = "SSH from admin networks"
}
ingress {
from_port = 3389
to_port = 3389
protocol = "tcp"
cidr_blocks = var.admin_cidr_blocks
description = "RDP from admin networks"
}
}
Lesson Learned: Always verify your current IP before applying network restrictions. I discovered my “home IP” was different from what I expected—would have locked myself out immediately.
Phase 2: IAM Policies (Identity Layer)
Scope: Specific IAM roles Risk Level: Medium Rollback: Detach policy
IAM policies add IP restrictions at the identity layer. The key insight: use a Deny policy with conditions rather than trying to add conditions to every Allow policy.
resource "aws_iam_policy" "ip_restriction" {
name = "IPRestriction-AllowedNetworks"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyNonApprovedIPs"
Effect = "Deny"
Action = "*"
Resource = "*"
Condition = {
NotIpAddress = {
"aws:SourceIp" = var.admin_cidr_blocks
}
Bool = {
"aws:ViaAWSService" = "false"
}
StringNotLike = {
"aws:PrincipalArn" = [
"arn:aws:sts::*:assumed-role/devops-operator/*",
"arn:aws:iam::*:role/aws-service-role/*"
]
}
}
}]
})
}
Critical Design Decisions:
-
aws:ViaAWSService = false: This exempts AWS services calling APIs on your behalf. Without this, CloudTrail can’t write to S3, Config can’t record resources, and Lambda functions fail mysteriously. -
StringNotLikefor role exemptions: GitLab CI/CD runs from dynamic IPs. Rather than trying to whitelist all possible GitLab runner IPs, exempt the OIDC-authenticated role entirely. -
Attach to specific roles, not all users: I attached this to
SecurityAnalystandOrganizationAdminroles—not my SSO session. This let me test safely before broader rollout.
Lesson Learned: IAM policy conditions use AND logic by default. All conditions must be true for the Deny to apply. This means the exemptions work correctly—if any exemption matches, the Deny doesn’t apply.
Phase 3: Break-Glass Access (Emergency Layer)
Scope: Emergency access from any IP Risk Level: None (additive) Rollback: N/A
Before restricting access further, I created an escape hatch. The break-glass user has no IP restrictions but requires MFA for any action.
resource "aws_iam_user" "break_glass" {
name = "break-glass-admin"
tags = {
Warning = "EMERGENCY USE ONLY - All actions logged"
}
}
resource "aws_iam_policy" "mfa_enforcement" {
name = "BreakGlass-MFAEnforcement"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyAllWithoutMFA"
Effect = "Deny"
NotAction = [
"iam:CreateVirtualMFADevice",
"iam:EnableMFADevice",
"iam:ListMFADevices",
"sts:GetSessionToken"
]
Resource = "*"
Condition = {
BoolIfExists = {
"aws:MultiFactorAuthPresent" = "false"
}
}
}]
})
}
Why This Matters: SCPs (next phase) can’t be bypassed by anyone in member accounts—not even account root. If you lock yourself out with an SCP, the break-glass user in the management account is your only recovery path.
Lesson Learned: Create your emergency access before you need it. Store credentials in a password manager, set up MFA immediately, and test that it works.
Phase 4: Service Control Policies (Organization Layer)
Scope: All member accounts in the organization Risk Level: High Rollback: Detach SCP
SCPs are the nuclear option. They create guardrails that no one can bypass—not IAM admins, not account root users, not anyone. They’re perfect for organization-wide security controls.
resource "aws_organizations_policy" "ip_restriction" {
name = "IPRestriction-AllowedNetworks"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyAccessFromNonApprovedIPs"
Effect = "Deny"
Action = "*"
Resource = "*"
Condition = {
NotIpAddress = {
"aws:SourceIp" = var.admin_cidr_blocks
}
Bool = {
"aws:ViaAWSService" = "false"
}
ArnNotLike = {
"aws:PrincipalArn" = [
"arn:aws:sts::*:assumed-role/devops-operator/*",
"arn:aws:iam::*:role/aws-service-role/*",
"arn:aws:iam::*:user/break-glass-admin",
"arn:aws:sts::*:assumed-role/OrganizationAccountAccessRole/*"
]
}
}
}]
})
}
Critical: SCPs don’t apply to the management account. This is intentional—you need somewhere to manage the organization from. But it means Phase 2 (IAM policies) is still necessary for management account protection.
Phase 5: Iterative Rollout
I didn’t apply the SCP to all accounts at once. The rollout was:
- Create SCP without attaching it
- Attach to Sandbox OU (one non-critical account)
- Test thoroughly: Can I access? Can GitLab deploy? Do AWS services work?
- Attach to Root (all member accounts)
- Monitor CloudTrail for any denied requests
flowchart LR
A["Create SCP"] --> B["Attach to Sandbox OU"]
B --> C{"Testing"}
C -->|"Pass"| D["Attach to Root"]
C -->|"Fail"| E["Fix Exemptions"]
E --> C
D --> F["Monitor CloudTrail"]
style A fill:#74c0fc
style B fill:#ffd43b
style C fill:#ff922b
style D fill:#51cf66
style F fill:#845ef7
Lesson Learned: Test on a single account/OU before organization-wide rollout. The feedback loop is much faster, and mistakes affect fewer resources.
Key Takeaways
1. Layer Your Controls
No single mechanism covers everything. Security groups protect network access, IAM policies protect identity-based access, and SCPs provide organization-wide guardrails. Each catches what the others miss.
2. Order Matters
Start with the lowest-risk changes (security groups), build up to higher-risk changes (SCPs). Always create emergency access before you might need it.
3. Exemptions Are Critical
Your security controls must account for:
- CI/CD pipelines with dynamic IPs
- AWS services calling APIs on your behalf
- Cross-account access roles
- Emergency/break-glass access
4. Test Incrementally
Apply changes to one account or OU first. Verify everything works before expanding scope. CloudTrail is your friend for debugging denied requests.
5. Document Your Decisions
Future you (or your team) will want to know why the GitLab role is exempted, why break-glass exists, and what IPs are allowed. Comments in Terraform and architecture diagrams pay dividends.
The Result
My AWS organization now restricts all API access to two IP addresses, with carefully designed exemptions for automation and emergencies. The defense-in-depth approach means even if one layer fails or is misconfigured, the others provide protection.
Is this overkill for a personal learning environment? Maybe. But implementing it taught me more about AWS access control than any certification study guide—and the infrastructure is now genuinely more secure.
Resources
This implementation was built with Terraform and deployed via GitLab CI/CD. The full source code is available in my infrastructure repository.