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:

    1. 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.

    2. StringNotLike for 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.

    3. Attach to specific roles, not all users: I attached this to SecurityAnalyst and OrganizationAdmin roles—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:

    1. Create SCP without attaching it
    2. Attach to Sandbox OU (one non-critical account)
    3. Test thoroughly: Can I access? Can GitLab deploy? Do AWS services work?
    4. Attach to Root (all member accounts)
    5. 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.