Serverless removes servers from your estate; it does not remove auditors from your calendar. Teams that ship on Lambda eventually land in a SOC 2 Type II audit, a HIPAA Business Associate Agreement, or a customer security questionnaire asking where personal data physically lives. The questions are the same ones a VM estate gets — encryption, access control, audit trail, retention, change management — but the evidence lives in different places, and a lot of it has to be configured in serverless.yml before it exists at all.
This post walks through the controls we implement on compliance-driven Serverless Framework V4 engagements, with the configuration that produces the evidence.
Start with the shared responsibility split
AWS gives you the physical and hypervisor layer. For Lambda, AWS also owns patching the runtime and the firecracker microVM. What remains yours, and what an auditor will test:
| Control area | Your responsibility on Lambda |
|---|---|
| Encryption at rest | Customer-managed KMS keys on DynamoDB, S3, SQS, logs, env vars |
| Encryption in transit | TLS enforcement on API Gateway, CloudFront, S3 bucket policies |
| Access control | IAM roles per function, least privilege, no shared deploy user |
| Audit trail | CloudTrail (management and data events), log retention |
| Change management | Pipeline-only deploys, PR review, immutable artefacts |
| Data residency | Region pinning, no cross-region replication by accident |
| Vulnerability management | Dependency scanning of your bundle |
| Incident response | Alarms, runbooks, evidence they were exercised |
Nothing in that list is exotic. The failure mode is that none of it is explicit in a fast-moving serverless repo, so the audit turns into three weeks of archaeology.
Check the service is actually in scope
Before you design anything, confirm the AWS services you use are covered by the compliance programme you need. AWS publishes Services in Scope by Compliance Program; Lambda, API Gateway, DynamoDB, S3, SQS, EventBridge, Step Functions, and Cognito are all HIPAA-eligible and in SOC scope, but newer or regional services sometimes are not, and eligibility varies by region. A HIPAA BAA only covers eligible services, so one in-scope-but-ineligible service in your pipeline invalidates the architecture.
Write the finding down in the repo. A short COMPLIANCE.md listing every AWS service the stack uses, its eligibility, and the region it runs in is the single most useful artefact you can hand an auditor on day one.
Encryption: make the keys yours
Default AWS-owned encryption satisfies many auditors; customer-managed KMS keys (CMKs) satisfy all of them and give you a key-usage audit trail in CloudTrail. Create the key in the stack and point everything at it.
resources:
Resources:
AppKey:
Type: AWS::KMS::Key
Properties:
Description: ${self:service}-${sls:stage} application data key
EnableKeyRotation: true
KeyPolicy:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root' }
Action: 'kms:*'
Resource: '*'
- Effect: Allow
Principal: { Service: logs.${aws:region}.amazonaws.com }
Action: ['kms:Encrypt*','kms:Decrypt*','kms:ReEncrypt*','kms:GenerateDataKey*','kms:Describe*']
Resource: '*'
PatientTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
SSESpecification:
SSEEnabled: true
SSEType: KMS
KMSMasterKeyId: !Ref AppKey
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
AttributeDefinitions: [{ AttributeName: pk, AttributeType: S }]
KeySchema: [{ AttributeName: pk, KeyType: HASH }]
Two Lambda-specific notes:
- Environment variables are encrypted at rest by default, but with an AWS-owned key. Set
provider.kmsKeyArn(or per-functionkmsKeyArn) to your CMK so key use is logged. Better still, keep secrets out of environment variables entirely and read them from Secrets Manager or SSM Parameter Store at init time — auditors dislike secrets visible in the console to anyone withlambda:GetFunctionConfiguration. - CloudWatch log groups are not encrypted with your key unless you say so. Lambda auto-creates log groups; declare them yourself so you control encryption and retention.
provider:
name: aws
runtime: nodejs22.x
region: eu-west-1
kmsKeyArn: !GetAtt AppKey.Arn
logRetentionInDays: 365
environment:
SECRET_ARN: !Ref AppSecret
iam:
role:
statements:
- Effect: Allow
Action: ['secretsmanager:GetSecretValue']
Resource: !Ref AppSecret
- Effect: Allow
Action: ['kms:Decrypt','kms:GenerateDataKey']
Resource: !GetAtt AppKey.Arn
In transit: no plaintext paths
- API Gateway HTTP APIs are TLS-only by default; set a minimum TLS version of 1.2 on any custom domain.
- Deny non-TLS access to S3 explicitly — auditors look for this bucket policy by name:
UploadsBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref UploadsBucket
PolicyDocument:
Statement:
- Effect: Deny
Principal: '*'
Action: 's3:*'
Resource:
- !Sub '${UploadsBucket.Arn}'
- !Sub '${UploadsBucket.Arn}/*'
Condition:
Bool: { 'aws:SecureTransport': false }
- If functions sit in a VPC to reach a database, use VPC endpoints for S3, DynamoDB, Secrets Manager, and KMS so that traffic never traverses a NAT gateway and the public internet.
Keep PHI and PII out of your logs
This is the control serverless teams fail most often. console.log(event) in a handler writes the full request body — including health data, card numbers, or tokens — into CloudWatch, where it is retained for a year and readable by anyone with log access.
Three mitigations, applied together:
- Structured logging with explicit fields. Powertools for AWS Lambda lets you log a correlation ID and a tenant ID without ever dumping the payload.
- Redaction at the logger. Powertools supports it natively; so does
pinowithredact: ['body.ssn','headers.authorization']. - A CI check. Fail the build on bare
console.log(event)orJSON.stringify(event)with an ESLint rule or a grep step. It is crude and it works.
Then use CloudWatch Logs data protection policies to mask known identifier patterns as a backstop:
LogDataProtection:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/${self:service}-${sls:stage}-api
RetentionInDays: 365
KmsKeyId: !GetAtt AppKey.Arn
DataProtectionPolicy:
Name: mask-pii
Version: '2021-06-01'
Statement:
- Sid: audit
DataIdentifier:
- arn:aws:dataprotection::aws:data-identifier/EmailAddress
- arn:aws:dataprotection::aws:data-identifier/Ssn-US
Operation: { Audit: { FindingsDestination: {} } }
- Sid: redact
DataIdentifier:
- arn:aws:dataprotection::aws:data-identifier/EmailAddress
- arn:aws:dataprotection::aws:data-identifier/Ssn-US
Operation: { Deidentify: { MaskConfig: {} } }
The audit trail: CloudTrail data events
Management-event CloudTrail is on by default and tells an auditor who changed a function. It does not tell them who read an object from S3 or an item from DynamoDB. For HIPAA-style "who accessed the record" questions you need data events, which are opt-in and billed per event:
- S3 object-level read/write events on buckets holding regulated data.
- DynamoDB item-level events on the tables holding it.
- Lambda
Invokeevents if you must prove which principal invoked a function.
Enable them in the organisation trail (an account-level control, usually owned by the platform team rather than this repo) and point the trail at a write-once S3 bucket with Object Lock in a separate logging account. Note the cost: data events on a chatty table can dwarf the table's own bill, so scope them to the regulated tables only.
Change management auditors can verify
The evidence an auditor wants here is mechanical: every production change was reviewed, tested, and traceable to a ticket, and no human can bypass it.
- No human deploy credentials in production. Deploys run from CI with GitHub Actions OIDC assuming a role restricted by a
subcondition on the branch and environment. Nobody holds long-lived keys. - Branch protection requiring review, status checks, and a linear history. Export the ruleset as a screenshot or API response once per audit period.
- Immutable artefacts. Build once, deploy the same zip or image digest to staging then production.
- Deployment records. CloudFormation stack events plus the CI run log tie a production change to a commit, a reviewer, and a timestamp. That is usually all the evidence SOC 2 CC8.1 needs — but only if deploys genuinely never happen from laptops.
A serverless deploy from an engineer's machine on a Friday night is a control failure even if the code was fine. Enforce it by removing production deploy permissions from everyone's SSO role.
Data residency and tenancy
For GDPR, UK, Swiss, Australian, or Canadian residency commitments, pin everything to one region and prove it:
- Set
provider.regionand never rely on an engineer'sAWS_REGION. - Apply an SCP or AWS Organizations region restriction so resources cannot be created elsewhere.
- Check the hidden cross-region paths: Route 53 and CloudFront are global, Lambda@Edge replicates your code worldwide (use CloudFront Functions or regional origins instead if that matters), CloudWatch Synthetics canaries run where you create them, and Bedrock cross-region inference profiles will silently route prompts to another region. Each of those has caught a team mid-audit.
- Document DynamoDB global tables and S3 replication rules explicitly, or turn them off.
Vulnerability management for a bundle, not a server
There is no OS to patch, but the auditor will still ask about vulnerability management. Answer with:
npm audit --audit-level=highor Dependabot/Renovate in CI, with a documented SLA for fixes.- Container image scanning with Amazon ECR enhanced scanning if you ship container-image functions.
- A runtime deprecation policy: AWS retires Lambda runtimes on a published schedule, and running a deprecated runtime is a finding. Keep a dated note of which runtime each service uses and when it ends support.
Detective controls and evidence collection
Turn on the managed detectors rather than writing your own:
- AWS Config with conformance packs for HIPAA or SOC 2 — these continuously evaluate rules such as "DynamoDB tables encrypted with CMK" and produce timestamped compliance history, which is exactly the format auditors want.
- Security Hub with the AWS Foundational Security Best Practices standard, which includes Lambda-specific checks (public function policies, deprecated runtimes, VPC configuration).
- GuardDuty, including Lambda Protection for malicious network activity from function code.
- Alarms with recipients: a CloudWatch alarm on DLQ depth, 5xx rate, and failed deploys, routed to an on-call channel. Keep the incident tickets; "we have alarms" is weaker evidence than "here are four incidents detected and resolved in the period".
A pre-audit checklist
Run this against the repo a month before fieldwork, not a week:
COMPLIANCE.mdlists every AWS service, its eligibility, and its region.- All regulated data stores use a CMK with rotation enabled, declared in the stack.
- All log groups are declared with retention and encryption; no auto-created ones remain.
- No handler logs raw request bodies; a CI rule enforces it; a data protection policy backs it up.
- CloudTrail data events are on for regulated buckets and tables, landing in a locked logging account.
- No human has production deploy rights; CI uses OIDC with a branch-scoped trust policy.
- IAM is per function, with no
*resources outside explicitly justified exceptions. - Backups exist and a restore has actually been tested this period (DynamoDB PITR restore, S3 versioning).
- AWS Config conformance pack and Security Hub are enabled, with findings triaged.
- An access review of the AWS accounts and the Serverless Framework dashboard was done and minuted.
Most of these are one serverless.yml change each. Done early, they cost a sprint; done during fieldwork, they cost the audit.
SleekDeploy's Serverless Security Consulting practice runs this assessment against Serverless Framework estates and hands back the gap list, the configuration to close it, and the evidence pack. If you have an audit coming and a Lambda estate nobody has reviewed, get in touch.