Cloud Security Hardening for Small Business & SaaS
80% of cloud breaches are preventable with basics. Here's the practical playbook for a 2-10 person team.
Every major 2024-2025 cloud breach was a known, preventable misconfiguration
IAM Hardening
IAM misconfigurations are the #1 cause of cloud breaches. Lock this down first.
The Non-Negotiables
- MFA on every human account - FIDO2/passkeys for admins, TOTP minimum for everyone else
- Delete root access keys - the root account should have MFA and nothing else
- No long-lived access keys - use IAM roles, OIDC federation for CI/CD, and IAM Identity Center for humans
- Least privilege - never
Action: "*". Use IAM Access Analyzer to right-size policies.
# Lock down root account
aws iam delete-access-key --user-name root --access-key-id AKIAXXXXXXXX
# Enable IAM Access Analyzer (free)
aws accessanalyzer create-analyzer \
--analyzer-name account-analyzer \
--type ACCOUNT
# Block public S3 at the account level (prevents ALL future public buckets)
aws s3control put-public-access-block \
--account-id $(aws sts get-caller-identity --query Account --output text) \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
Least Privilege - What It Actually Looks Like
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-app-uploads/*"
},
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:us-east-1:*:secret:production/*"
},
{
"Effect": "Allow",
"Action": ["sqs:SendMessage", "sqs:ReceiveMessage", "sqs:DeleteMessage"],
"Resource": "arn:aws:sqs:us-east-1:*:my-app-queue"
}
]
}
CI/CD - No More Access Keys
# GitHub Actions OIDC (zero stored credentials)
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: us-east-1
Network Security
| Control | What | Priority |
|---|---|---|
| Private subnets | RDS, app servers, caches in private subnets. Only ALB/CloudFront public. | 🔴 Critical |
| Security groups | No 0.0.0.0/0 except on ALB port 443. Restrict SSH to VPN/bastion only. | 🔴 Critical |
| VPC Flow Logs | Enable on all VPCs. Send to S3 (cheaper) or CloudWatch. | 🟡 High |
| VPC Endpoints | PrivateLink for S3, DynamoDB, Secrets Manager - keeps traffic off the internet. | 🟡 High |
| WAF | AWS WAF on ALB/CloudFront. OWASP managed rules + rate limiting. | 🟡 High |
| TLS 1.2+ | Enforce on all load balancers and CloudFront. Use TLSv1.2_2021 policy. | 🔴 Critical |
WAF - Minimum Viable Rules
# Create a WAF WebACL with AWS managed rules
aws wafv2 create-web-acl \
--name "saas-protection" \
--scope REGIONAL \
--default-action '{"Allow":{}}' \
--rules '[
{
"Name": "AWSManagedRulesCommonRuleSet",
"Priority": 1,
"Statement": {
"ManagedRuleGroupStatement": {
"VendorName": "AWS",
"Name": "AWSManagedRulesCommonRuleSet"
}
},
"OverrideAction": {"None": {}},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "CommonRules"
}
},
{
"Name": "RateLimit",
"Priority": 2,
"Statement": {
"RateBasedStatement": {
"Limit": 2000,
"AggregateKeyType": "IP"
}
},
"Action": {"Block": {}},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "RateLimit"
}
}
]' \
--visibility-config '{"SampledRequestsEnabled":true,"CloudWatchMetricsEnabled":true,"MetricName":"SaaSProtection"}'
Data Protection & Encryption
| Service | Encryption at Rest | Encryption in Transit |
|---|---|---|
| S3 | SSE-S3 (default) or SSE-KMS for sensitive data | Enforce via bucket policy aws:SecureTransport |
| RDS | Enable at creation (can't add later without recreation) | Force SSL via parameter group rds.force_ssl=1 |
| DynamoDB | Encrypted by default (AWS owned key or KMS) | TLS by default |
| EBS | Enable default encryption in account settings | N/A (block storage) |
| Secrets | Secrets Manager (auto-rotation) or SSM Parameter Store | TLS by default |
# Force HTTPS on S3 bucket
aws s3api put-bucket-policy --bucket my-bucket --policy '{
"Version": "2012-10-17",
"Statement": [{
"Sid": "ForceHTTPS",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}}
}]
}'
# Enable default EBS encryption for the account
aws ec2 enable-ebs-encryption-by-default
# Set up Secrets Manager with auto-rotation
aws secretsmanager create-secret \
--name production/db-password \
--secret-string '{"username":"app","password":"CHANGE_ME"}' \
--kms-key-id alias/production-key
Application Security
The OWASP Essentials
- Injection: Parameterized queries everywhere. Never concatenate user input into SQL/commands.
- Broken auth: Use OAuth 2.0/OIDC via Cognito or Auth0. Short-lived JWTs (15 min access, 7 day refresh).
- SSRF: Validate and allowlist outbound URLs. Block access to
169.254.169.254(EC2 metadata). - Dependency vulns: Dependabot (free), Snyk, or Trivy in CI/CD. Block merges with critical CVEs.
Security Headers (CloudFront Response Headers Policy)
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
CI/CD Security Scanning
# GitHub Actions - scan on every PR
- name: Dependency scan
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
severity: HIGH,CRITICAL
exit-code: 1
- name: SAST scan
uses: returntocorp/semgrep-action@v1
with:
config: p/owasp-top-ten
Logging & Monitoring
| Service | Cost | What It Catches |
|---|---|---|
| CloudTrail | Free (mgmt events) | Every API call. Who did what, when, from where. |
| GuardDuty | ~$30-50/mo (small) | Compromised instances, credential abuse, crypto mining, recon. |
| Security Hub | ~$10-30/mo | CIS benchmark compliance, aggregates findings from all services. |
| IAM Access Analyzer | Free | Resources shared outside your account (public S3, cross-account roles). |
| Config | $0.003/rule eval | Configuration drift, non-compliant resources. |
Critical Alerts (Set Up Day 1)
# Alert on root account login
aws cloudwatch put-metric-alarm \
--alarm-name "RootAccountLogin" \
--metric-name "RootAccountUsage" \
--namespace "CloudTrailMetrics" \
--statistic Sum \
--period 300 \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--evaluation-periods 1 \
--alarm-actions arn:aws:sns:us-east-1:*:security-alerts
# Alert on security group changes
# Alert on IAM policy changes
# Alert on S3 bucket policy changes
# (Same pattern - create CloudWatch metric filters from CloudTrail)
Incident Response
Compromised Credentials Playbook
- Disable the key/user immediately -
aws iam update-access-key --status Inactive - Check CloudTrail - what did the attacker do? Look for
CreateUser,AttachPolicy,RunInstances - Revoke all sessions - attach an inline deny-all policy, then rotate credentials
- Check for persistence - new IAM users, new access keys, Lambda backdoors, EC2 instances in unusual regions
- Contain blast radius - isolate affected resources, revoke cross-account access
- Notify - legal, customers (if data exposed), AWS support (if needed)
Auto-Remediation: Disable Compromised Keys
# Lambda triggered by GuardDuty finding via EventBridge
import boto3
def handler(event, context):
finding = event['detail']
if finding['type'].startswith('UnauthorizedAccess:IAMUser'):
access_key = finding['resource']['accessKeyDetails']['accessKeyId']
user = finding['resource']['accessKeyDetails']['userName']
iam = boto3.client('iam')
iam.update_access_key(
UserName=user,
AccessKeyId=access_key,
Status='Inactive'
)
# Notify via SNS
sns = boto3.client('sns')
sns.publish(
TopicArn='arn:aws:sns:us-east-1:*:security-alerts',
Subject=f'ALERT: Access key disabled for {user}',
Message=f'GuardDuty detected unauthorized access. Key {access_key} disabled.'
)
Multi-Tenant SaaS Security
| Pattern | Isolation | Cost | Best For |
|---|---|---|---|
| Silo | Separate DB/infra per tenant | High | Regulated industries, enterprise |
| Pool | Shared DB, tenant_id column | Low | Most SaaS startups |
| Bridge | Shared infra, separate schemas/tables | Medium | Mid-market SaaS |
Row-Level Security (PostgreSQL)
-- Enable RLS on the table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their tenant's data
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Set tenant context at the start of each request
SET app.current_tenant = 'tenant-uuid-here';
-- Now SELECT * FROM orders only returns that tenant's rows
-- Even if application code has a bug, RLS prevents cross-tenant access
SOC 2 & Compliance
SOC 2 for SaaS Startups
| What | Cost | When |
|---|---|---|
| Compliance automation (Vanta/Drata/Secureframe) | $10K-$20K/year | Start collecting evidence from day 1 |
| SOC 2 Type 1 audit | $15K-$30K | When enterprise prospects require it (~$50K+ deals) |
| SOC 2 Type 2 audit | $20K-$50K | 6-12 months after Type 1 (covers a review period) |
What auditors actually look for: MFA enforced, access reviews documented, change management process, incident response plan, encryption at rest and in transit, logging enabled, vulnerability scanning, employee security training. Most of this is what you should be doing anyway.
Real-World Breaches (2024-2025)
git-secrets pre-commit hooks + GitHub push protection (free).
Action: "*" because "it works." When the app is compromised via SSRF or dependency vuln, the attacker inherits full account access. Prevention: least-privilege policies from day 1.
Common thread: Every major 2024-2025 cloud breach was a known, preventable misconfiguration. Not zero-days. Not sophisticated attacks. Automated scanners, basic misconfigs, minutes to exploitation.
Week-by-Week Implementation Plan
Week 1: Stop the Bleeding
- Enable S3 Block Public Access at account level
- Enable MFA on all IAM users (FIDO2 for admins)
- Delete all root access keys
- Enable CloudTrail (multi-region)
- Enable GuardDuty (free 30-day trial)
- Verify no public RDS instances
Week 2: Access Control
- Set up IAM Identity Center (SSO)
- Replace access keys with OIDC federation for CI/CD
- Implement least-privilege roles for applications
- Enable IAM Access Analyzer
- Add git-secrets pre-commit hooks
Week 3: Network & Encryption
- Audit VPC: RDS/app servers in private subnets
- Review security groups (no 0.0.0.0/0 on non-ALB resources)
- Enable RDS encryption + force SSL
- Enable default EBS encryption
- Enforce TLS 1.2+ on all load balancers
Week 4: Detection & Response
- Enable Security Hub with CIS benchmark
- Set up CloudWatch alarms (root login, IAM changes, SG changes)
- Write IR playbook (compromised credentials, data breach)
- Deploy auto-remediation Lambda for compromised keys
- Enable VPC Flow Logs
Month 2-3: Hardening
- Deploy WAF on public-facing ALB/CloudFront
- Implement dependency scanning in CI/CD (Trivy + Semgrep)
- Set up Secrets Manager with rotation
- Add security headers via CloudFront
- Implement SCPs (region restriction, prevent security service disable)
Month 3-6: Compliance
- Sign up for compliance automation (Vanta/Drata/Secureframe)
- Document access control, change management, and IR procedures
- Implement row-level security for multi-tenant data
- Begin SOC 2 Type 1 preparation
The Bottom Line
Cloud security for a small team isn't about buying expensive tools - it's about not leaving the door open. S3 Block Public Access, MFA, least-privilege IAM, and CloudTrail cost almost nothing and prevent 80% of breaches. Start with Week 1, iterate weekly, and you'll have a security posture that passes SOC 2 audits within 6 months. The attackers are automated - your defenses should be too.