
Disclaimers :
Opinions expressed in this post (and in any of all my posts) are solely, unless otherwise specified, those of the authors, me. Those opinions absolutely do not reflect the views, policies, positions of any organizations, employers, affiliated groups.
I don't agree with this, but my employer says I don't have the right to share the source code I've written in the course of my work. I don't ever want a problem. So, I'll try to say as much as I can without divulging any specific information.
I've strived for accuracy throughout this piece, but if you catch any errors, please reach out—I'd be grateful for the feedback and happy to make updates!
Hook
Welcome to the second episode of this series. Already three days in the training. The pace is fast, there are many concepts to master, fatigue and exhaustion are setting in. Today, we'll talk about the security and audit systems that determine whether your cloud infrastructure can be trusted with sensitive data. The previous episode can be found here.
Table of contents
- Security and Audit - How AWS enforces permissions and tracks access
- IAM Policies - Policy evaluation logic and types
- OpenID Connect (OIDC) - Federated identity and cross-organizational access
- KMS and ACM - Encryption and certificate management
- CloudTrail - Audit logging and compliance
Security and Audit
In the previous article, we introduced IAM: users, groups, roles, policies, and the principle of least privilege. That was the "who can do what" overview. Today we go deeper into the how — how policies are actually evaluated, how to avoid hardcoding credentials entirely (even across organizations), how to encrypt things, and how to know exactly what happened in your account at 3am last Tuesday.
IAM Policies: how AWS actually decides
You write a policy, attach it to a user or role, and hope for the best. But what happens when multiple policies apply? When one says Allow and another says Deny? AWS follows a specific evaluation logic:
- Default deny — everything is denied unless explicitly allowed
- Explicit deny wins — if any policy says Deny, it's denied, period
- Allow must be present — the action must be explicitly allowed by at least one policy
- Boundaries are checked — SCPs, permission boundaries, and session policies can further restrict
Think of it like this: you need a "yes" from every gatekeeper, and a single "no" from anyone overrides everything.
Types of policies:
| Policy Type | Attached To | Purpose |
|---|---|---|
| Identity-based | Users, Groups, Roles | Grant permissions to an identity |
| Resource-based | S3 buckets, SQS queues, etc. | Control who can access this resource |
| Permission boundaries | Users, Roles | Set the maximum permissions an identity can have |
| Service Control Policies | Organization accounts | Guardrails across entire accounts |
| Session policies | Temporary sessions | Limit permissions for a specific session |
Permission boundaries deserve special attention. They don't grant permissions — they set a ceiling on what an identity can do. Think of it as a safety net: an identity policy can allow an action, but if the permission boundary doesn't include it, the action is blocked.
Example:
- Permission boundary allows:
s3:*andec2:DescribeInstances(read-only) - Identity policy allows:
s3:*,ec2:*(full EC2 access) - Effective result: The user can do all S3 actions and only read EC2, not modify it
A permission boundary is a maximum limit. The effective permissions are the intersection (overlap) of:
- What the permission boundary allows
- What the identity policy allows
This is useful when you want to let developers create their own IAM roles. You set a permission boundary that says "you can have S3 and read-only EC2," and even if a developer accidentally adds a policy that grants ec2:TerminateInstances, that action is blocked by the boundary. The boundary prevents privilege escalation.
A more realistic policy example:
The day 1 article showed a simple S3 read policy. Here's what a real-world policy looks like:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadSharedDatasets",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::research-datasets",
"arn:aws:s3:::research-datasets/shared/*"
],
"Condition": {
"StringEquals": {
"aws:RequestedRegion": "eu-west-3"
}
}
},
{
"Sid": "UploadOwnResults",
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::research-datasets/results/${aws:username}/*"
},
{
"Sid": "DenyUnencryptedUploads",
"Effect": "Deny",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::research-datasets/*",
"Condition": {
"StringNotEquals": {
"s3:x-amz-server-side-encryption": "aws:kms"
}
}
}
]
}
You read it like this (Sid -> Effet -> Action -> Resource -> Condition):
- "ReadSharedDatasets: Allow s3:GetObject, s3:ListBucket on research-datasets when in eu-west-3"
- "UploadOwnResults: Allow s3:PutObject on own results datasets owned by current user with username"
- "DenyUnencryptedUploads: Deny s3:PutObject on research-datasets when there is no KMS encryption"
You understand it like this:
- "ReadSharedDatasets: Allow reading from the shared research-datasets bucket, but only from eu-west-3"
- "UploadOwnResults: Allow uploading to your personal results folder"
- "DenyUnencryptedUploads: Deny uploading unencrypted files to the research-datasets bucket"
Now tet's imagine the following interaction:
A researcher tries to upload an unencrypted file to their results folder:
- Policy 2 says: "You can upload files" ✅
- Policy 3 says: "But NOT unencrypted files" ❌
- Result: Upload is DENIED — because Explicit deny wins (rule #2 from the evaluation logic: Explicit deny wins — if any policy says Deny, it's denied, period)
Notice three things:
- Conditions restrict where actions can happen (only
eu-west-3) - Policy variables like
${aws:username}scope access per current user dynamically - Explicit Deny blocks everything A researcher can't bypass this, even if they accidentally add a permissive policy.
Why do we mention this?
To reinforce the critical IAM evaluation rule: "A single Deny overrides all Allows". This is one of the most important security principles in AWS.
Creating an IAM Role (practical example):
Let's say you have an EC2 instance running a Python script that needs to read from S3 and write to DynamoDB. Here's how you'd do it properly with the AWS CLI:
Step 1 — Create the trust policy (who can assume this role):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
Reading this in plain English:
"Allow the EC2 service to assume this role (ResearchAnalysisRole)"
Breaking it down:
- Principal (
ec2.amazonaws.com) = WHO can do this → the EC2 service - Action (
sts:AssumeRole) = WHAT they can do → take on this role's permissions - Effect (
Allow) = permission granted
Step 2 — Create the role and attach the trust policy:
aws iam create-role \
--role-name ResearchAnalysisRole \
--assume-role-policy-document file://trust-policy.json
Key distinction:
- Trust policy (Step 1): "Who can assume this role?" → Answer: The EC2 service
- Permissions policy (Step 3): "What can this role do?" → Answer: Read S3, write to DynamoDB
Step 3 — Create and attach the permissions policy:
First, create the permissions-policy.json file with the permissions the role needs:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadFromS3",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::research-data",
"arn:aws:s3:::research-data/*"
]
},
{
"Sid": "WriteToDynamoDB",
"Effect": "Allow",
"Action": ["dynamodb:PutItem", "dynamodb:UpdateItem"],
"Resource": "arn:aws:dynamodb:eu-west-3:123456789012:table/analysis-results"
}
]
}
Now attach this permissions policy to the role:
aws iam put-role-policy \
--role-name ResearchAnalysisRole \
--policy-name ResearchAnalysisPermissions \
--policy-document file://permissions-policy.json
Step 4 — Create an instance profile and attach the role:
aws iam create-instance-profile \
--instance-profile-name ResearchAnalysisProfile
aws iam add-role-to-instance-profile \
--instance-profile-name ResearchAnalysisProfile \
--role-name ResearchAnalysisRole
Now any EC2 instance launched with this profile gets temporary credentials based on the ResearchAnalysisRole's permissions. automatically. No access keys to manage, no secrets to rotate, no .env file to accidentally commit inside the Git repository. Your Python script just calls boto3.client('s3') and it works, the SDK picks up the credentials from the instance metadata.
The complete picture:
+------------------------------------------------------------+
| IAM Role: ResearchAnalysisRole |
+------------------------------------------------------------+
| Trust Policy: |
| "Allow EC2 service to assume this role" |
| |
| Permission Policy: |
| - Allow S3: GetObject, ListBucket |
| - Allow DynamoDB: PutItem, UpdateItem |
+------------------------------------------------------------+
|
| (attached to)
|
+------------------------------------------------------------+
| Instance Profile: ResearchAnalysisProfile |
| (container that holds the role) |
+------------------------------------------------------------+
|
| (attached to)
|
+------------------------------------------------------------+
| EC2 Instance |
+------------------------------------------------------------+
| |
| +--------------------------------------------------+ |
| | Python Script (running on instance) | |
| | | |
| | import boto3 | |
| | s3 = boto3.client('s3') | |
| | db = boto3.client('dynamodb') | |
| +--------------------------------------+-----------+ |
| | |
| +--------------------------------------+---+ |
| | Instance Metadata Service | | |
| | (provides temporary creds) ----------+ | |
| | | | |
| +--------------------------------------+---+ |
| | |
+-----------------------------------------+------------------+
|
|
+------------+-------------------+
| |
+---+---+ +----+--------+
| S3 | | DynamoDB |
|(read) | | (write) |
+-------+ +-------------+

OpenID Connect (OIDC)
So far, IAM handles identities inside AWS. But what about the outside world? What if your GitHub Actions pipeline needs to deploy to AWS? What if your researchers log in through your organization's Active Directory?
OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0. It allows external identity providers to authenticate users and services, which can then assume AWS roles. No long-lived credentials stored anywhere.
How it works in practice:
Step 1: Authenticate with Identity Provider
+---------------------------+
| GitHub / Google / Azure AD|
| (Identity Provider) |
+---------------------------+
^
|
| "Login with GitHub"
|
+--+--+
|User |
+--+--+
|
| Step 2: Receive JWT Token
| (cryptographic proof of identity)
v
+---------------------------+
| JWT Token |
| Contains: |
| - Repository name |
| - Branch |
| - Commit SHA |
| - Timestamp |
+---------------------------+
|
| Step 3: Send token to AWS STS
v
+---------------------------+
| AWS STS |
| (Security Token Service) |
+---------------------------+
|
| Step 4: STS checks trust policy
| "Is this token from the repo
| I trust? YES -> OK"
|
v
+---------------------------+
| Temporary AWS Credentials |
| - Access Key ID |
| - Secret Access Key |
| - Session Token |
| - Expires in 1 hour |
+---------------------------+
The flow:
- User/Service authenticates with an external identity provider (GitHub, Google, Azure AD, corporate LDAP)
- Provider issues a JSON Web Token, a cryptographic proof containing metadata (repository, branch, user identity, etc.)
- Token is sent to AWS STS (Security Token Service), STS examines it
- STS checks the trust policy — "Is this token from a trusted source?" If yes, temporary credentials are issued
- Credentials expire automatically — typically 1 hour, so no manual cleanup needed
Real-world example — GitHub Actions deploying to AWS:
Instead of storing AWS access keys as GitHub secrets, you configure an OIDC identity provider in AWS:
# .github/workflows/deploy.yml
jobs:
deploy:
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubDeployRole
aws-region: eu-west-3
- run: aws s3 sync ./build s3://my-app-bucket
Security: What if someone forks your repo?
If someone forks your repo, they can't assume your role. Here's why:
- The Github OIDC token includes metadata: repository name, branch, commit SHA, etc.
- Your trust policy is specific: it explicitly checks: "Only allow tokens from
organization/original-repo-name" - The fork has different metadata: when the fork's workflow runs, the token says
fork-owner/forked-repo - The policy rejects it: AWS compares:
fork-owner/forked-repo≠organization/original-repo-name→ Denied - Result: No credentials, no access — Even if the attacker has your exact workflow file, the OIDC token won't match the trust policy
Why this matters for a biomedical research center:
Researchers often come from partner institutions, universities, or international organizations. Instead of creating and managing IAM users for each collaborator, you can federate identity through their home institution's identity provider. They log in with their university credentials, get temporary AWS access scoped to exactly what they need, and when their collaboration ends, you revoke nothing — their access simply stops when the trust relationship is removed.

KMS and ACM: encryption and certificates
AWS KMS (Key Management Service)
Manages encryption keys for your data. If you're storing patient data, genomic sequences, or any sensitive research data, encryption isn't optional — it's a compliance requirement (HIPAA, GDPR, etc.).
Key concepts:
| Concept | What it does |
|---|---|
| Customer Master Key (CMK) | The top-level key. Never leaves AWS. You control who can use it. IAM policies define access. |
| Data key | Generated by KMS, used to actually encrypt your data. You use it locally, then discard it. |
| Key policies | IAM-like policies controlling who can use or manage the key. Auditable via CloudTrail. |
| Automatic rotation | AWS rotates key material annually. Old data remains decryptable. Compliance requirement. |
How envelope encryption works:
Step 1: Request data key from KMS
Application --> KMS: "Give me a data key"
Step 2: KMS returns both plaintext and encrypted key
KMS --> Application:
- Plaintext key (256-bit AES key)
- Encrypted key (encrypted with CMK)
Step 3: Encrypt data locally with plaintext key
Application:
- Encrypts patient data with plaintext key
- Discards plaintext key from memory
Step 4: Store encrypted data + encrypted key
Database stores:
- Encrypted patient data (gigabytes)
- Encrypted data key (tiny, ~200 bytes)
Step 5: To decrypt later
Application: "KMS, decrypt this key for me"
KMS: Decrypts it, returns plaintext key
Application: Uses plaintext key to decrypt patient data
Why this design? Sending gigabytes of sensitive data to KMS would be slow, expensive, and create a bottleneck. Instead, KMS only handles small cryptographic keys, and the heavy encryption happens locally in your application. Best of both worlds: security + performance.
Practical example — patient records:
1. Patient uploads medical scan (500 MB)
2. You request a data key from KMS (costs ~$0.03)
3. Application encrypts scan with plaintext key
4. Plaintext key is immediately discarded
5. Database stores:
- Encrypted scan (500 MB, unreadable)
- Encrypted data key (200 bytes, small)
6. The CMK stays locked in KMS (never exported, never leaves AWS)
7. To decrypt: send encrypted key to KMS, get plaintext key
8. Audit trail in CloudTrail: "Who decrypted what and when"
AWS-managed vs. Customer-managed keys:
| Type | Cost | Control | Use when |
|---|---|---|---|
| AWS-managed | Free | Limited (AWS rotates annually) | Most AWS services (S3, RDS, DynamoDB by default) |
| Customer-managed | ~$1/month + API calls | Full (you choose rotation, who can access, audit trail) | Compliance-heavy (HIPAA, GDPR, biomedical data) |
For biomedical research with patient data, customer-managed keys are typically required because auditors need to see exactly who accessed the encryption keys and when (CloudTrail).
Handles TLS/SSL certificates for your domains. If you've wrestled with Let's Encrypt renewals, ACM is the managed equivalent.
Public certificates (Free):
- Domain validation: ACM verifies you own the domain
- Use with: ALB, CloudFront, API Gateway
- Automatic renewal: No more "certificate expired at 3am" alerts
- Wildcard support:
*.example.comcovers all subdomains
Private CA (Paid):
- For internal services that need TLS but aren't exposed to the internet
- Example: Your research database server communicates with Python scripts using mutual TLS
- Cost: ~$400/month for the CA, plus per-certificate fees
- Use when: Internal services need encrypted communication, but you control both client and server
The tradeoff:
- ACM public certificates: Free, automatic, but only work with AWS services
- Let's Encrypt: Free, but you manage renewal automation yourself
- ACM private CA: Paid, but manages all internal TLS automatically
For biomedical research centers: use free public certificates for your website (ACM + ALB), and use HTTP/2 between internal services (encryption handled by VPC itself, no need for certificates).

CloudTrail: who did what, and when
AWS CloudTrail records every API call made in your AWS account. Every. Single. One. Someone launched an EC2 instance? Logged. Someone changed a security group? Logged. Someone tried to access an S3 bucket and was denied? Logged.
What CloudTrail captures:
| Field | Example |
|---|---|
| Who | arn:aws:iam::123456789012:user/jdoe |
| What | RunInstances, PutBucketPolicy, DeleteObject |
| When | 2026-02-04T14:32:17Z |
| Where | eu-west-3, source IP 203.0.113.42 |
| How | Console, CLI, SDK, or assumed role |
| Result | Success or failure (with error code) |
Event types:
- Management events: Control plane operations (creating resources, changing configurations). Logged by default.
- Data events: Data plane operations (reading S3 objects, invoking Lambda functions). Not logged by default — you must explicitly enable them. They're high-volume and cost money.
- Insights events: CloudTrail analyzes your patterns and flags unusual activity (sudden spike in API calls, unusual error rates).
Practical example — investigating a security incident:
A researcher reports they can't access their S3 bucket anymore. You suspect someone changed the bucket policy. With CloudTrail and CloudWatch Logs Insights:
fields @timestamp, userIdentity.arn, eventName, requestParameters.bucketName
| filter eventSource = "s3.amazonaws.com"
| filter eventName like /PutBucket/
| filter requestParameters.bucketName = "research-datasets"
| sort @timestamp desc
| limit 20
This query shows you exactly who modified the bucket policy, when, and from where. No more guessing, no more "it wasn't me." The audit trail is immutable.
Best practices:
- Enable CloudTrail in all regions — attacks often target regions you're not watching
- Send logs to a centralized S3 bucket in a dedicated security account — so attackers can't delete their tracks even if they compromise a workload account
- Enable log file integrity validation — CloudTrail can detect if someone tampered with log files
- Set up CloudWatch alarms for critical events: root account login, IAM policy changes, security group modifications, console sign-in failures
The traditional infrastructure equivalent:
| CloudTrail Feature | Traditional Equivalent |
|---|---|
| Management events | auditd on Linux, syslog for config changes |
| Data events | Application-level access logs (Apache, Nginx logs) |
| Insights | Custom anomaly detection scripts, SIEM correlation rules |
| Log integrity | AIDE, Tripwire, signed log shipping |
| Multi-region logging | Centralized syslog server, rsyslog forwarding |
In the biomedical research context:
CloudTrail is required for compliance. HIPAA mandates audit trails for access to protected health information. GDPR requires you to demonstrate who accessed personal data and why. CloudTrail, combined with AWS Config (which tracks resource configuration changes over time), gives you a complete audit story.
Equivalence in the Linux world
The concepts we've covered aren't AWS innovations. They're fundamental security patterns that exist in every operating system. Here's how AWS services map to traditional Linux/Unix equivalents:
| AWS Service | Linux/Unix Equivalent | Purpose |
|---|---|---|
| IAM Policies | /etc/sudoers, file permissions (rwx), RBAC | Define who can do what |
| Permission Boundaries | AppArmor, SELinux policies | Set maximum permissions ceiling |
| OIDC (Federated Identity) | LDAP, Kerberos, PAM modules | External identity provider authentication |
| Trust Policy | /etc/sudoers, PAM rules | Define who can assume a role/user |
| KMS (Encryption Keys) | GPG, OpenSSL, PKCS#11, HSM | Manage encryption keys centrally |
| ACM (Certificates) | Let's Encrypt + certbot, OpenSSL | Manage TLS/SSL certificates |
| CloudTrail (Audit) | auditd, syslog, journalctl | Immutable audit logs of system activity |
Why AWS then? These equivalents work great on Linux, but AWS eliminates the plumbing work, automates, standardizes, and integrates these patterns across hundreds of services. AWS provides enterprise-grade reliability.
If Day 1 was about "where and what," Day 2-3 has been about "how and who." Security and audit should not be something you think about after. They're the foundation of any production infrastructure, especially even more for people handling sensitive data like what we do with 4S.
The patterns here generalize beyond AWS: whether you're running on-premises or on another cloud, the principles remain the same:
- Default deny, explicit allow: the principle of least privilege applied everywhere
- Separate the identity layer: authentication (who you are) from authorization (what you can do)
- **Keep an immutable audit trail: you can't defend what you can't prove
- Encrypt by default: both in transit and at rest
- Use temporary credentials: no long-lived secrets scattered across your infrastructure
I don't think I have fully understood and mastered everything. I don't think I can explore everything in a single article. If you want to dive deeper into AWS security and audit, I strongly recommend the following links: