Guide · AWS Organizations · Service Control Policies

AWS Organizations SCPs from MCP Server — CreatePolicy, AttachPolicy, evaluation logic

Service Control Policies (SCPs) define the maximum permissions available to accounts in your AWS organization. From an MCP server you create SCPs with CreatePolicyCommand and attach them to roots, OUs, or individual accounts with AttachPolicyCommand. The critical concept is SCPs do not grant permissions — they only restrict them: effective permissions for an IAM principal in a member account = IAM permissions ∩ all applicable SCPs. If an SCP doesn't explicitly allow an action, the action is denied even if the IAM policy allows it. SCPs never apply to the management account itself.

TL;DR

Create an SCP with CreatePolicyCommand{Content: JSON, Name, Description, Type: 'SERVICE_CONTROL_POLICY'}. Attach it with AttachPolicyCommand{PolicyId, TargetId} where TargetId is a root ID (r-), OU ID (ou-), or account ID (12-digit). The FullAWSAccess policy (p-FullAWSAccess) is attached to the root by default — do not detach it unless you're applying an allowlist strategy. Up to 5 SCPs per target, up to 1000 SCPs per org. SCP content max 5120 characters.

SCP evaluation logic

Understanding SCP evaluation prevents accidental lockouts. The effective permission for any action is the intersection of:

  1. The IAM identity-based policy (must Allow the action)
  2. All SCPs on the account's path from root to account (all must Allow or none must Deny)
IAM policySCP resultEffective permission
AllowAllow (FullAWSAccess)Allowed
AllowDeny (explicit Deny statement)Denied
AllowNot present in any SCP AllowDenied (implicit)
DenyAllowDenied (IAM deny wins)
Not presentAllowDenied (no IAM grant)

The FullAWSAccess managed SCP (p-FullAWSAccess) allows all actions on all resources and is the default on every root and new OU. With only FullAWSAccess attached, IAM policies in member accounts function normally. The two SCP strategies are:

Creating and attaching SCPs

import {
  OrganizationsClient,
  CreatePolicyCommand,
  AttachPolicyCommand,
  DetachPolicyCommand,
  DeletePolicyCommand,
  ListPoliciesCommand,
  DescribePolicyCommand,
  ListPoliciesForTargetCommand,
  ListTargetsForPolicyCommand,
  UpdatePolicyCommand,
} from '@aws-sdk/client-organizations';

const org = new OrganizationsClient({ region: 'us-east-1' });

export async function createSCP(params: {
  name: string;
  description: string;
  content: object;  // the SCP JSON document
}): Promise<string> {  // returns PolicyId
  const response = await org.send(new CreatePolicyCommand({
    Name: params.name,
    Description: params.description,
    Type: 'SERVICE_CONTROL_POLICY',
    Content: JSON.stringify(params.content),  // must be a JSON string, max 5120 chars
  }));
  return response.Policy!.PolicySummary!.Id!;  // e.g. 'p-examplepolicyid111'
}

export async function attachSCP(policyId: string, targetId: string): Promise<void> {
  await org.send(new AttachPolicyCommand({ PolicyId: policyId, TargetId: targetId }));
}

export async function detachSCP(policyId: string, targetId: string): Promise<void> {
  await org.send(new DetachPolicyCommand({ PolicyId: policyId, TargetId: targetId }));
}

// Delete only works if the policy has no attached targets
export async function deleteSCP(policyId: string): Promise<void> {
  await org.send(new DeletePolicyCommand({ PolicyId: policyId }));
}

export async function updateSCPContent(policyId: string, content: object): Promise<void> {
  await org.send(new UpdatePolicyCommand({
    PolicyId: policyId,
    Content: JSON.stringify(content),
  }));
}

// List all SCPs in the org
export async function listAllSCPs() {
  const policies = [];
  let nextToken: string | undefined;

  do {
    const response = await org.send(new ListPoliciesCommand({
      Filter: 'SERVICE_CONTROL_POLICY',
      NextToken: nextToken,
    }));
    policies.push(...(response.Policies ?? []));
    nextToken = response.NextToken;
  } while (nextToken);

  return policies;
}

// List SCPs attached to a specific target (root/OU/account)
export async function listSCPsForTarget(targetId: string) {
  const policies = [];
  let nextToken: string | undefined;

  do {
    const response = await org.send(new ListPoliciesForTargetCommand({
      TargetId: targetId,
      Filter: 'SERVICE_CONTROL_POLICY',
      NextToken: nextToken,
    }));
    policies.push(...(response.Policies ?? []));
    nextToken = response.NextToken;
  } while (nextToken);

  return policies;
}

// List all targets a policy is attached to
export async function listTargetsForSCP(policyId: string) {
  const targets = [];
  let nextToken: string | undefined;

  do {
    const response = await org.send(new ListTargetsForPolicyCommand({
      PolicyId: policyId,
      NextToken: nextToken,
    }));
    targets.push(...(response.Targets ?? []));
    nextToken = response.NextToken;
  } while (nextToken);

  return targets;  // each has TargetId, Name, Arn, Type (ROOT/OU/ACCOUNT)
}

Common SCP patterns

These are the most frequently deployed SCP guardrails. Each is a denylist SCP — safe to attach alongside FullAWSAccess.

Deny actions outside approved regions

const denyNonApprovedRegions = {
  Version: '2012-10-17',
  Statement: [
    {
      Sid: 'DenyOutsideApprovedRegions',
      Effect: 'Deny',
      NotAction: [
        // Global services that always use us-east-1 — must be excluded
        'iam:*',
        'organizations:*',
        'route53:*',
        'budgets:*',
        'waf::*',
        'cloudfront:*',
        'sts:*',
        'support:*',
        'trustedadvisor:*',
      ],
      Resource: '*',
      Condition: {
        StringNotEquals: {
          'aws:RequestedRegion': ['us-east-1', 'us-west-2', 'eu-west-1'],
        },
      },
    },
  ],
};

The NotAction list is essential — global services (IAM, Organizations, Route 53, CloudFront, etc.) always make API calls to us-east-1 regardless of your selected region. Without the NotAction exclusions, IAM role creation and DNS operations will fail.

Require MFA for sensitive actions

const requireMFAForSensitiveActions = {
  Version: '2012-10-17',
  Statement: [
    {
      Sid: 'DenyHighRiskActionsWithoutMFA',
      Effect: 'Deny',
      Action: [
        'iam:CreateAccessKey',
        'iam:DeleteAccessKey',
        'iam:UpdateAccessKey',
        'iam:CreateUser',
        'iam:DeleteUser',
        'iam:AttachRolePolicy',
        'iam:PutRolePolicy',
        's3:DeleteBucket',
        'ec2:TerminateInstances',
      ],
      Resource: '*',
      Condition: {
        BoolIfExists: { 'aws:MultiFactorAuthPresent': 'false' },
      },
    },
  ],
};

Prevent leaving the organization

const preventLeaveOrg = {
  Version: '2012-10-17',
  Statement: [
    {
      Sid: 'DenyLeaveOrganization',
      Effect: 'Deny',
      Action: 'organizations:LeaveOrganization',
      Resource: '*',
    },
  ],
};

Protect security tooling

const protectSecurityTooling = {
  Version: '2012-10-17',
  Statement: [
    {
      Sid: 'DenyDisablingSecurityServices',
      Effect: 'Deny',
      Action: [
        'guardduty:DeleteDetector',
        'guardduty:DisassociateFromMasterAccount',
        'securityhub:DisableSecurityHub',
        'config:StopConfigurationRecorder',
        'config:DeleteConfigurationRecorder',
        'config:DeleteDeliveryChannel',
        'cloudtrail:DeleteTrail',
        'cloudtrail:StopLogging',
        'macie2:DisableMacie',
        'access-analyzer:DeleteAnalyzer',
      ],
      Resource: '*',
    },
  ],
};

Enabling SCP type (if not already enabled)

SCP type must be enabled on the root before you can attach any SCP other than FullAWSAccess. In most orgs it's already enabled, but if you're setting up a new org programmatically, enable it first.

import {
  EnablePolicyTypeCommand,
  DescribePolicyCommand,
} from '@aws-sdk/client-organizations';

export async function enableSCPType(rootId: string): Promise<void> {
  try {
    await org.send(new EnablePolicyTypeCommand({
      RootId: rootId,
      PolicyType: 'SERVICE_CONTROL_POLICY',
    }));
  } catch (err: any) {
    // PolicyTypeAlreadyEnabledException is expected if SCPs were already on
    if (err.name !== 'PolicyTypeAlreadyEnabledException') throw err;
  }
}

Org feature set must be ALL (not CONSOLIDATED_BILLING) for SCP type to be enabled. Use DescribeOrganizationCommand to check Organization.FeatureSet before attempting to enable.

Error reference

ErrorCauseFix
PolicyNotAttachedExceptionDetachPolicyCommand on a policy not attached to the targetCheck ListPoliciesForTargetCommand first
PolicyInUseExceptionDeletePolicyCommand when policy is still attached to targetsCall DetachPolicyCommand on all targets first
DuplicatePolicyAttachmentExceptionAttachPolicyCommand when policy is already on that targetCheck ListPoliciesForTargetCommand before attaching
PolicyTypeNotEnabledExceptionTrying to attach SCPs before enabling SCP type on rootCall EnablePolicyTypeCommand first
MalformedPolicyDocumentExceptionSCP JSON is invalid (syntax error or invalid IAM grammar)Validate JSON; check for NotAction/NotResource usage correctness
PolicySizeExceededExceptionSCP content exceeds 5120 charactersSplit into multiple SCPs; use wildcards to consolidate action lists

Related pages