01 The Brief

The most expensive mistakes in the cloud almost never start with a clever exploit. They start with an over-permissioned key committed to a public repo, a root account with no MFA, or a runaway resource nobody was watching. So the very first Bitwyse project isn't a flashy deployment — it's the boring, load-bearing work that makes every later month safe.

The brief I set myself: take a fresh AWS account and turn it into a governed foundation before a single workload touches it. That means treating the root user as a break-glass credential I hope to never use, giving every human and machine identity exactly the access it needs and no more, and making the account observable and cost-bounded from day zero.

I'd call it done when I could tick every one of these:

  • Root has MFA, a strong password, and no access keys — and I never sign in with it again.
  • Human access happens through named identities with MFA enforced by policy.
  • A "developer" identity exists that can build things but cannot escalate its own privileges or read the bill.
  • Programmatic access uses roles, not long-lived user keys.
  • CloudTrail records every API call to a bucket that's hard to tamper with.
  • A budget alarm reaches my inbox well before I'd ever be surprised by a bill.

02 The Blueprint

I think of an AWS account as a building, and identity as its foundation poured in layers. Each layer only makes sense once the one beneath it is solid, so I sketched the account as a stack — identity at the base, access controls on top, then the two things that keep the whole structure honest: an audit trail and cost guardrails.

Root user MFA ON · NO ACCESS KEYS · BREAK-GLASS ONLY IDENTITY Who gets in HUMAN & MACHINE PRINCIPALS IAM Identity Center IAM users · MFA enforced IAM roles password policy · 14+ chars ACCESS What they can do LEAST-PRIVILEGE · GROUP-BASED grp: Admins grp: Developers grp: Billing customer-managed policies + explicit MFA deny AUDIT Everything is recorded CloudTrail · all regions S3 · encrypted logs TAMPER-RESISTANT LOG TRAIL COST Nothing runs away AWS Budgets SNS email
The foundation, poured in four layers — identity, access, audit, cost guardrails.

Why these choices

A few decisions were deliberate rather than default:

  • Groups, never per-user policies. Attaching permissions to a group and putting people in it means access is described in one place. When a role changes, I move the person, not fifteen inline policies.
  • Roles for anything programmatic. Long-lived user access keys are the number-one way credentials leak. Roles hand out short-lived, auto-rotating credentials instead — so later projects assume roles rather than carry keys.
  • MFA enforced by an explicit deny. Asking people to enable MFA is a suggestion. A policy that denies almost everything unless the session is MFA-authenticated is a rule.
  • CloudTrail before anything interesting happens. An audit trail is only useful if it was already running when something went wrong. So it goes in on day one, not after an incident.
Design note

This is a single-account build on purpose. AWS Organizations and a full multi-account landing zone are the grown-up version — but the identity habits here (least privilege, MFA, roles, audit) are exactly the ones that scale into it later. Walk, then run.

03 The Build

I worked top-down through the stack. Every step below was done deliberately and, wherever it made sense, through the CLI so it's repeatable and reviewable rather than a memory of clicks.

Step 1 — Disarm the root user

First job: make root boring. I signed in one last time to set a long unique password, turn on a virtual MFA device, and confirm there were no root access keys (if there had been, they'd be deleted immediately). Then I set an account-wide password policy so every future identity has to meet a real bar.

bashset-password-policy.sh
# Enforce a strong baseline for every IAM user in the account
aws iam update-account-password-policy \
  --minimum-password-length 14 \
  --require-symbols --require-numbers \
  --require-uppercase-characters --require-lowercase-characters \
  --max-password-age 90 --password-reuse-prevention 5

Step 2 — Groups first, then people

Rather than create a user and bolt permissions on, I created the containers for permission first: an Admins group for break-glass administration, a Developers group for day-to-day building, and a Billing group for read-only cost visibility. Only then did I create my own daily-driver user and drop it into Developers.

bashgroups.sh
aws iam create-group --group-name Admins
aws iam create-group --group-name Developers
aws iam create-group --group-name Billing

# Admins get the managed admin policy (break-glass, MFA-gated below)
aws iam attach-group-policy --group-name Admins \
  --policy-arn arn:aws:iam::aws:policy/AdministratorAccess

# My daily user lives in Developers — never in Admins
aws iam create-user --user-name bitwyse.dev
aws iam add-user-to-group --user-name bitwyse.dev --group-name Developers

Step 3 — Write least-privilege for the Developer group

This is the heart of the project. The developer identity should be able to build and inspect the services I'll actually use — but it should be unable to touch IAM, read the bill, or turn off the audit trail. I wrote a customer-managed policy that grants what's needed and layers an explicit deny on the sensitive stuff.

jsondeveloper-boundary.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BuildAndInspect",
      "Effect": "Allow",
      "Action": ["ec2:*", "s3:*", "cloudwatch:*", "logs:*"],
      "Resource": "*"
    },
    {
      "Sid": "HandsOffTheKeys",
      "Effect": "Deny",
      "Action": ["iam:*", "organizations:*",
                 "aws-portal:*", "cloudtrail:StopLogging",
                 "cloudtrail:DeleteTrail"],
      "Resource": "*"
    }
  ]
}
The mental model that clicked

In IAM, an explicit Deny always wins over any Allow. That single rule is what lets a broad "Allow ec2:*" coexist safely with "but never touch IAM." I stopped thinking about permissions as a whitelist and started thinking in guardrails.

Step 4 — Make MFA non-optional

A password alone isn't enough. I attached a policy to the Developers group that denies essentially all actions when the request isn't backed by MFA — the one exception being the handful of actions a user needs to enrol their own MFA device in the first place.

jsonforce-mfa.json (excerpt)
{
  "Sid": "BlockEverythingWithoutMFA",
  "Effect": "Deny",
  "NotAction": ["iam:CreateVirtualMFADevice", "iam:EnableMFADevice",
               "iam:ListMFADevices", "iam:ResyncMFADevice", "sts:GetSessionToken"],
  "Resource": "*",
  "Condition": { "BoolIfExists": { "aws:MultiFactorAuthPresent": "false" } }
}

Step 5 — Turn on the audit trail

Next, an all-regions CloudTrail writing to a dedicated, encrypted, versioned S3 bucket with a policy that only lets the CloudTrail service principal write to it. Log file validation is switched on so I can later detect if anyone tampered with the record.

bashcloudtrail.sh
aws cloudtrail create-trail --name bitwyse-org-trail \
  --s3-bucket-name bitwyse-cloudtrail-logs \
  --is-multi-region-trail --enable-log-file-validation
aws cloudtrail start-logging --name bitwyse-org-trail

Step 6 — Set the money alarm

Finally, guardrails on spend. A monthly cost budget with alert thresholds at 50%, 80% and 100% of a deliberately low limit, wired to an SNS topic that emails me. On a learning account, a forgotten NAT gateway or oversized instance is the real threat — this makes sure I hear about it in hours, not on the statement.

04 Curveballs

Nothing here is conceptually hard, but IAM is unforgiving about detail. Three things genuinely tripped me up — the kind of thing tutorials skip.

ERRThe MFA policy locked me out of enabling MFA

My first "force MFA" policy used Action: Deny *, which — logically enough — also blocked the very calls needed to register an MFA device. Classic chicken-and-egg. Fix: switch Action to NotAction and carve out the MFA-enrolment and sts:GetSessionToken calls, so a user with no device yet can still bootstrap one.

ERRCloudTrail refused to write to the S3 bucket

InsufficientS3BucketPolicyException. The bucket existed, but it had no policy granting the CloudTrail service permission to s3:PutObject under the right prefix. Fix: attach a bucket policy with principal cloudtrail.amazonaws.com, the aws:SourceArn condition pinned to my trail, and the correct AWSLogs/<account-id>/ resource path.

ERRThe Billing group couldn't see billing

I attached the read-only billing policy, logged in as the billing user… and got access denied on the Billing console. Fix: IAM access to billing data is gated by an account-level switch — "IAM user and role access to Billing information" — that has to be turned on by root first. A permission policy alone isn't enough.

05 Proof It Works

A foundation you haven't tested is just a hope. So I validated each guarantee by trying to break it, not by trusting the console's green ticks.

  1. Least privilege holds. Signed in as bitwyse.dev and deliberately tried aws iam create-user and opened the Billing console — both correctly denied. The identity can build EC2 and S3 but can't grow its own power.
  2. MFA is really enforced. Started a fresh CLI session without MFA and watched almost every call fail with an explicit deny, then re-ran with a session token from an MFA device and watched them succeed.
  3. The trail is watching. Performed a couple of test actions, waited, and found them in the CloudTrail event history and the S3 log bucket — with log-file validation intact.
  4. The budget bites. Set a temporary $1 budget to force an alert and confirmed the SNS email actually landed in my inbox.
0root sign-ins after setup
100%identities with MFA
Allregions audited
3budget thresholds armed

The goal was never "a locked-down account." It was an account I could hand the keys to my future self and trust that he can't accidentally do something catastrophic.

06 Takeaways

IAM has a reputation for being tedious, and honestly it earns it — but reframing it as "designing the guardrails I get to build recklessly inside of" made it the opposite of a chore. A few things I'm taking into next month:

  • Explicit deny is a superpower. Broad allows plus targeted denies is far easier to reason about than trying to enumerate every allowed action.
  • Roles > keys, always. Nothing I build from here on will carry a long-lived access key if a role can do the job.
  • Set up observability before you need it. CloudTrail and Budgets cost almost nothing and are worthless if switched on after the incident.

What I'd change: I'd reach for AWS Organizations and IAM Identity Center sooner to keep production and experiments in separate accounts. That's the natural next evolution — but the identity discipline here transfers directly. With a trustworthy account in place, it's finally time to put something on the internet.

Up next — Month 02

First AWS Web Deployment — building a VPC from scratch and putting a hardened, HTTPS-served website online on top of this foundation.