CloudFormation Hooks are the least-used enforcement surface in the AWS IaC security stack. They let you intercept stack operations before CloudFormation calls the AWS service APIs — which means you can run policy evaluation logic against the resource configuration before it ever reaches IAM or the resource control plane.
Combined with SCP evaluation, Hooks give you a two-layer gate: the Hook fires first (at the CloudFormation orchestration layer), and the SCP fires second (at the IAM service API layer). If you're enforcing the same policy intent at both layers, the result is deterministic. If a stack passes the Hook but somehow generates API calls that violate an SCP, the SCP catches it. More importantly, if your Hook correctly mirrors your SCP logic, you get the failure early — with a useful error message at stack deploy time, rather than an opaque AccessDenied error from the service API.
How CloudFormation Hooks Work
A CloudFormation Hook is a Lambda function registered via the CloudFormation registry as a Hook type. You define the hook in a configuration file, specify which resource types and operations it applies to, and set a failure mode (either FAIL to block the operation, or WARN to log and proceed).
The Hook receives a target invocation point — PRE_CREATE, PRE_UPDATE, or PRE_DELETE — along with the resource properties from the template. Your Lambda function evaluates the properties and returns a SUCCESS or FAILED response. If the response is FAILED and your failure mode is FAIL, CloudFormation stops the stack operation.
Registering the hook type and activating it for an account are separate steps. The registration is per-region per-account (or per-account via StackSets if you're rolling out to the org). Activation with a specific type configuration is what actually enables the hook for CloudFormation operations in that account.
Writing a Hook That Mirrors SCP Logic
The goal is that every security condition your SCP enforces at the IAM layer, your Hook evaluates at the template layer. This means the feedback loop is: developer sees a Hook failure with a clear message in CloudFormation → understands exactly what to fix → fixes the template → re-runs the stack.
Here's a minimal Hook handler in Python that checks an S3 bucket template for public access settings:
import json
from cloudformation_cli_python_lib import (
BaseHookHandlerRequest,
HookContext,
OperationStatus,
ProgressEvent,
SessionProxy,
)
def pre_create_handler(
session: SessionProxy,
request: BaseHookHandlerRequest,
callback_context: dict,
) -> ProgressEvent:
target_model = request.hookContext.targetModel
resource_properties = target_model.get("resourceProperties", {})
# Check for public access block settings
public_access_block = resource_properties.get(
"PublicAccessBlockConfiguration", {}
)
required_blocks = [
"BlockPublicAcls",
"BlockPublicPolicy",
"IgnorePublicAcls",
"RestrictPublicBuckets",
]
missing = [
k for k in required_blocks
if not public_access_block.get(k, False)
]
if missing:
return ProgressEvent(
status=OperationStatus.FAILED,
message=(
f"S3 bucket must have PublicAccessBlockConfiguration "
f"with all four block settings enabled. "
f"Missing or disabled: {', '.join(missing)}. "
f"This mirrors SCP: DenyS3PublicAccess."
),
)
return ProgressEvent(status=OperationStatus.SUCCESS)
The key detail: the error message explicitly references the SCP it mirrors. When a developer sees this failure, they know it's not a random Hook — it's a surface-level check of the same control that exists at the IAM layer. This reduces the "why is my stack failing" debugging cycle significantly.
Registering and Activating the Hook
The hook configuration file (hook.json) specifies the target resource types and operations:
{
"CloudFormationConfiguration": {
"HookConfiguration": {
"TargetStacks": "ALL",
"FailureMode": "FAIL",
"Properties": {
"EncryptionAlgorithm": "aws:kms"
},
"Filters": {
"Targets": [
{
"TargetName": "AWS::S3::Bucket",
"Action": "CREATE",
"InvocationPoint": "PRE_PROVISION"
},
{
"TargetName": "AWS::S3::Bucket",
"Action": "UPDATE",
"InvocationPoint": "PRE_PROVISION"
}
]
}
}
}
}
Activate the hook for an account:
aws cloudformation activate-type \
--type HOOK \
--type-name MyOrg::Security::S3PublicAccessCheck \
--execution-role-arn arn:aws:iam::123456789012:role/CloudFormationHookRole \
--type-configuration-arn arn:aws:cloudformation:us-east-1::type-configuration/hook/...
aws cloudformation set-type-configuration \
--configuration-alias default \
--type-name MyOrg::Security::S3PublicAccessCheck \
--type HOOK \
--configuration file://hook.json
To roll out across an AWS Organization, use StackSets targeting your organization root or specific OUs. The same Hook registration runs across all member accounts, with account-specific IAM execution roles.
The Execution Role Requirement
CloudFormation Hooks run in the context of an execution role that your Lambda assumes during hook invocation. This role needs specific permissions depending on what your hook does. If your hook only reads the template properties (no AWS API calls), the role needs minimal permissions — just the ability to be assumed by the CloudFormation Hooks service principal.
If your hook makes AWS API calls to validate resource state (for example, checking whether a referenced IAM role has the required permission boundary), the execution role needs those read permissions. Scope the role narrowly. Hook execution roles are a potential privilege escalation surface if over-permissioned.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "hooks.cloudformation.amazonaws.com"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"aws:SourceAccount": "123456789012"
}
}
}
]
}
Covering IAM Resource Types
The most high-value hook target beyond S3 is IAM role creation — specifically enforcing that every IAM role created via CloudFormation has a permission boundary attached. This is the Hook-layer equivalent of the DenyAdminRoleWithoutBoundary SCP:
def check_iam_role(resource_properties: dict) -> ProgressEvent:
permission_boundary = resource_properties.get(
"PermissionsBoundary"
)
if not permission_boundary:
return ProgressEvent(
status=OperationStatus.FAILED,
message=(
"IAM Role must specify a PermissionsBoundary. "
"Approved boundary ARN: "
"arn:aws:iam::*:policy/OrgApprovedBoundary. "
"Mirrors SCP: DenyAdminRoleWithoutBoundary."
),
)
approved_prefix = "arn:aws:iam::*:policy/OrgApproved"
# In production, use a full ARN match or regex
if "OrgApproved" not in permission_boundary:
return ProgressEvent(
status=OperationStatus.FAILED,
message=(
f"PermissionsBoundary '{permission_boundary}' "
f"is not an approved boundary policy. "
f"Use a policy prefixed with OrgApproved."
),
)
return ProgressEvent(status=OperationStatus.SUCCESS)
Where Hooks Stop and SCPs Take Over
Hooks only fire for resources created or updated through CloudFormation. If someone uses the AWS Console, CLI, or SDK directly — bypassing CloudFormation entirely — the Hook doesn't fire. The SCP is what covers that surface.
This is why the two layers are complementary rather than redundant. The Hook is the developer-facing gate with readable error messages. The SCP is the infrastructure-level gate that applies regardless of the deployment path. For teams that have standardized on CloudFormation for all infrastructure changes, the Hook handles the vast majority of cases. For teams where ad-hoc console or CLI access exists, the SCP is the backstop.
We're not saying every team needs both layers for every control. For a small team that deploys everything through CloudFormation and has no console access, the Hook alone might be sufficient for specific controls. But "we only use IaC" is a claim that's easy to make and hard to guarantee over time. The SCP is cheap insurance.
Testing Hooks Before Production Rollout
Hook failures in CloudFormation surface differently than SCP failures. An SCP failure returns a generic AccessDenied from the service API. A Hook failure returns the exact message you wrote in the FAILED ProgressEvent. This makes Hooks much easier to debug — but it also means your Hook needs accurate error messages from the start.
Test your Hook in a non-production account first. The CloudFormation CLI plugin provides a local test runner:
cfn invoke --function-name PreCreateHookHandler \
--value '{"actionInvocationPoint":"PRE_CREATE","hookContext":{"targetName":"AWS::S3::Bucket","targetType":"RESOURCE","targetModel":{"resourceProperties":{"BucketName":"test-bucket"}}}}'
Test both the passing case (template includes required properties) and the failing case (template omits required properties). Validate that the error message is specific enough for a developer to understand the fix without reading documentation.
The combination of CloudFormation Hooks and SCPs gives you enforcement coverage across both the IaC orchestration layer and the service API layer. The two layers carry different information: the Hook gives you template-time property values and can provide developer-readable feedback; the SCP gives you IAM-time call conditions and catches everything that bypasses CloudFormation. Neither layer alone is complete.