Getting Off Root: AWS Identity Without Static Keys

The first post in this series locked down root and put an alarm on it. Those controls protect an identity you should almost never use. Most compromises start instead with the credentials you use every day.
"Stop using root for daily work" appears on every AWS checklist as if it were a setting. It is a behavioral change, and it only sticks once you have a day-to-day identity that can do the work and reach the parts of the console you need. Billing access is where that plan often fails.
The billing setting that blocks everything#
By default, IAM users and roles cannot open the Billing and Cost Management console at all. Not "cannot do much there." Cannot reach it. The AWS Billing docs are explicit that this holds even when the identity's IAM policies grant billing permissions.
Until you flip this, the budget you created in the last post, Cost Anomaly Detection, cost allocation tags, and budget actions are all root-only tasks. Which means the advice to stop using root contradicts itself.
Sign in as root, which is one of the few legitimate reasons to. Account menu, then Account, scroll to IAM user and role access to Billing information, Edit, check Activate IAM Access, Update. Then attach AWSBillingReadOnlyAccess or Billing to whatever identity you are about to create, following the example billing policies. The re:Post walkthrough covers the same ground if the console has moved since.
Billing Access
Accounts and organizations created on or after March 6, 2023 already have the fine-grained billing actions in effect, so write policies against the modern billing:, ce:, and budgets: action names rather than the legacy aws-portal: ones. Cost Explorer permissions are the odd one out: they apply across member accounts regardless of IAM policy.
Policy example for billing-only read-only access
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CostExplorerRead",
"Effect": "Allow",
"Action": [
"ce:DescribeCostCategoryDefinition",
"ce:DescribeNotificationSubscription",
"ce:DescribeReport",
"ce:GetAnomalies",
"ce:GetAnomalyMonitors",
"ce:GetAnomalySubscriptions",
"ce:GetApproximateUsageRecords",
"ce:GetCostAndUsage",
"ce:GetCostAndUsageWithResources",
"ce:GetCostCategories",
"ce:GetCostForecast",
"ce:GetDimensionValues",
"ce:GetPreferences",
"ce:GetReservationCoverage",
"ce:GetReservationPurchaseRecommendation",
"ce:GetReservationUtilization",
"ce:GetRightsizingRecommendation",
"ce:GetSavingsPlansCoverage",
"ce:GetSavingsPlansPurchaseRecommendation",
"ce:GetSavingsPlansUtilization",
"ce:GetSavingsPlansUtilizationDetails",
"ce:GetTags",
"ce:GetUsageForecast",
"ce:ListCostAllocationTags",
"ce:ListCostCategoryDefinitions",
"ce:ListTagsForResource"
],
"Resource": "*"
},
{
"Sid": "BudgetsRead",
"Effect": "Allow",
"Action": [
"budgets:ViewBudget",
"budgets:DescribeBudgetAction",
"budgets:DescribeBudgetActionHistories",
"budgets:DescribeBudgetActionsForAccount",
"budgets:DescribeBudgetActionsForBudget"
],
"Resource": "*"
},
{
"Sid": "CurDefinitionsRead",
"Effect": "Allow",
"Action": [
"cur:DescribeReportDefinitions"
],
"Resource": "*"
},
{
"Sid": "DenySensitiveBillingSurfaces",
"Effect": "Deny",
"Action": [
"payments:*",
"invoicing:*",
"tax:*",
"purchase-orders:*",
"consolidatedbilling:*",
"billing:GetBillingData",
"billing:GetBillingDetails",
"billing:GetBillingPreferences",
"billing:GetContractInformation",
"billing:GetCredits",
"billing:GetSellerOfRecord",
"billing:Put*",
"billing:Update*",
"billing:Redeem*",
"account:GetAccountInformation",
"account:GetContactInformation",
"account:GetAlternateContact",
"cur:PutReportDefinition",
"cur:ModifyReportDefinition",
"cur:DeleteReportDefinition",
"ce:Create*",
"ce:Update*",
"ce:Delete*",
"ce:Provide*",
"ce:Start*",
"ce:Tag*",
"ce:Untag*"
],
"Resource": "*"
}
]
}
IAM Identity Center, for one person#
The old pattern was an IAM user with AdministratorAccess, an access key, and a line in ~/.aws/credentials. Those keys do not expire, they end up in shell history and .env files and container images, and they are what the campaigns in the first post are hunting for (and I have a post about that).
Identity Center replaces that with short-lived credentials from a sign-in portal. For a standalone account it works with a built-in identity store, so there is no external IdP to run.
Enable Identity Center
Enable IAM Identity Center in your home region. The identity store is global but the instance has a region, and that region determines your portal URL, so pick the one that makes sense (e.g. us-west-2 if you work in that region). Create a user for yourself. Then create two permission sets: an AdminAccess set backed by the AWS-managed AdministratorAccess policy with a short session duration, and a scoped PowerUserDaily set for routine work. Assign your user to the account with both.
The split is less about least privilege in the abstract and more about limiting what a stolen laptop session can reach. If two permission sets feel like overhead for one person, one admin set with a short session is a defensible middle ground, provided you pair it with the region locks and quota ceilings in the next post.
Wire up the CLI with the modern sso-session flow, which needs CLI v2.22.0 or later:
aws configure sso
# SSO start URL: https://<id>.awsapps.com/start
# SSO region, session name
That writes an [sso-session] block and a [profile] into ~/.aws/config, which is what enables automatic token refresh. After that, aws sso login --sso-session my-sso opens the browser and your terminal gets temporary credentials.
Session duration has two independent knobs. The access-portal session is configurable from 15 minutes to 90 days, defaulting to 8 hours, after AWS raised the old 7-day ceiling in September 2023. The permission-set session duration is separate and caps at 12 hours. There is also a long-standing CLI quirk where aws sso login caches an 8-hour token regardless of the configured maximum, so plan your re-auth cadence around what your CLI actually does rather than what the console says.
Identity Center itself is free.
What root is still for#
Once Identity Center works, root has a short and boring job list: changing the account email, root password, or root MFA; changing the support plan in some cases; closing the account; restoring IAM permissions after a self-inflicted lockout; unlocking an S3 or SQS deny-all bucket policy; a handful of billing and tax settings; and registering as a seller.
Finish the task and leave. Everything else goes through the portal.
Credential hygiene on the laptop#
Getting off root does not help if the machine is still full of long-lived secrets.
Keep plaintext long-lived keys out of ~/.aws/credentials. With Identity Center, the on-disk config points at a profile while the actual credentials are temporary and cached. If Identity Center genuinely does not fit your setup, tools like aws-vault, which keeps credentials in the OS keychain and hands out session tokens get you most of the way there.
If you suspect the key got compromized, deactivate first with aws iam update-access-key --status Inactive --access-key-id <id-here>, because deactivation is instant and reversible while deletion is neither. Create a replacement, migrate what uses it, then delete the old one. Audit CloudTrail across every region, not just the one you work in.
Kill the last static key with OIDC#
If you deploy from GitHub Actions, that workflow probably holds the last long-lived AWS key in your account. OIDC federation removes it entirely: GitHub mints a short-lived JWT for each run, an IAM role trusts GitHub's OIDC provider, and aws-actions/configure-aws-credentials exchanges the token for temporary credentials. Nothing durable is left to steal.
The two references worth keeping open while you set this up:
Configuring OpenID Connect in Amazon Web Services - GitHub Docs
Use OpenID Connect within your workflows to authenticate with Amazon Web Services.
docs.github.com/actions/deployment/security-hardening-you...GitHub - aws-actions/configure-aws-credentials: Configure AWS credential environment variables for use in other GitHub Actions.
Configure AWS credential environment variables for use in other GitHub Actions. - aws-actions/configure-aws-credentials
github.com/aws-actions/configure-aws-credentialsThe field that determines whether this is secure is sub. Researchers found multiple companies in 2023 whose trust policies omitted the sub condition entirely, meaning any repository on GitHub could assume the role. AWS now blocks creation of a trust policy whose sub matches everything, but the condition still has to name the repository and the ref you intend:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:<ORG>/<REPO>:ref:refs/heads/main"
}
}
}]
}
Use StringEquals on a specific ref where you can. If you need multiple branches, StringLike with repo:<ORG>/<REPO>:* works, but understand that you have just authorized every branch and every pull request in that repository.
Since January 2026, AWS STS supports provider-specific claim keys such as GitHub's repository and environment, so a policy can require both a branch and a named deployment environment. Repositories created on or after July 15, 2026, plus older repos that opt in, also emit an immutable sub claim carrying numeric organization and repository IDs. That closes the name-recycling attack where someone re-registers a deleted org name and mints tokens matching your stale policy.
The workflow side is three lines of permissions and an action:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/github-actions-role
aws-region: us-west-2
Scope the role to what the deployment actually needs, and add an aws:RequestedRegion condition to it while you are there. OIDC credentials default to a 60-minute lifetime.
Confirm the S3 defaults#
S3 needs verification rather than more setup. Since 2023, it automatically enables Block Public Access and disables ACLs for all new buckets in all regions. At the same time, every bucket has also had SSE-S3 default encryption, with all new objects encrypted automatically.
"Secure by default" is doing some work in that sentence, though. Fog Security's audit of what Block Public Access actually covers is the clearest account of where the gaps still are:
The State of AWS's Block Public Access: Is It Secure By Default?
A look at Amazon Web Services's Block Public Access feature across AWS services such as EC2, S3, EMR, DynamoDB and how secure by default principles apply to account configuration to add additional layers of security as well as best practices for configuration.
fogsecurity.io/blog/aws-block-public-access-stateWhat is worth setting explicitly is the account-level block, which overrides per-bucket settings:
aws s3control put-public-access-block --account-id <id> \
--public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
Amplify uses S3 for build artifacts and hosting behind CloudFront, and account-level Block Public Access does not interfere with that, because CloudFront reaches the bucket through Origin Access Control rather than public ACLs. The one thing it does break is deliberately hosting a public S3 website, so if that is ever your plan, know that the account setting wins over the bucket setting.
SSE-S3 protects data at rest. It does nothing about a bucket policy that hands the data to the internet.
Make the trail worth having#
The last post created a CloudTrail trail to catch root sign-ins. A trail an attacker can quietly edit or delete is not useful evidence, and one with no retention policy is a slow cost leak. It needs validation, bucket protection, and an expiry rule.
Turn on log-file validation. CloudTrail hashes each log file and delivers an hourly digest referencing the previous hour's files, signed with a per-region private key:
aws cloudtrail update-trail --name <trail> --enable-log-file-validation
aws cloudtrail validate-logs --trail-arn <arn> --start-time 2026-08-01T00:00:00Z
The console pre-checks this box for new trails, but the API default is false. If you created the trail with the CLI or Terraform, verify rather than assume, and note that validate-logs is region-specific and only checks files still in their original location. Disabling validation later breaks the digest chain after an hour, and CloudTrail does not backfill the gap.
Protect the bucket. Turn on versioning, keep account-level Block Public Access on, and scope the bucket policy to the CloudTrail service principal plus your admin identity and nothing else.
Age the logs. Add an S3 lifecycle rule expiring objects at something like 400 days. If you enabled versioning, expire noncurrent versions too, or deleted objects accumulate forever as versions you are still paying for. Glacier transitions look attractive until you notice that at small object counts the per-request transition charges exceed the storage savings, so plain expiration is usually the right rule here. If you also route the trail to a CloudWatch log group, set retention explicitly, because the default is Never expire:
aws logs put-retention-policy --log-group-name <lg> --retention-in-days 90
Lock the domain at the registrar#
Everything above defends the AWS account. But if you have any domains in your Route53, none of it defends the domain, and domain hijacking bypasses all of it. If someone transfers your domain away, your DNS records, your certificate, and your website become irrelevant in about an hour.
Transfer lock is off by default and has to be enabled per domain. Route 53, Registered domains, your domain, Actions, Turn on transfer lock. Confirm auto-renew is on while you are there.
aws route53domains enable-domain-transfer-lock --region us-east-1 --domain-name example.com
aws route53domains get-domain-detail --region us-east-1 --domain-name example.com \
--query StatusList
Domain Transfer Lock
The command only runs in us-east-1. Success shows clientTransferProhibited in the status list. In Terraform:
resource "aws_route53domains_registered_domain" "example" {
domain_name = "example.com"
auto_renew = true
transfer_lock = true
}
You have to turn the lock off before a legitimate transfer out, or the transfer will keep failing. The registrant contact email also has to stay reachable: ICANN requires verification, and an unanswered verification email can suspend the domain no matter how well configured the AWS side is. Turn on privacy protection so that address is not harvestable from WHOIS.
What this costs#
Nothing, except the CloudTrail storage you were already paying cents for. Identity Center is free. OIDC is free. gitleaks is free. Secret scanning is free on public repositories. Transfer lock is free.
| Control | Recurring cost |
|---|---|
| Activate IAM billing access | $0 |
| IAM Identity Center, permission sets, CLI SSO | $0 |
| GitHub Actions OIDC | $0 |
| Secret scanning and push protection | $0 public, paid per committer for private repos |
| gitleaks pre-commit | $0 |
| S3 Block Public Access, default encryption | $0 |
| CloudTrail validation, versioning, lifecycle | $0 beyond storage |
| Domain transfer lock, auto-renew | $0 |
An account that has worked through these controls has no long-lived credentials left worth stealing, and a tamper-evident record of what happens in it. The next post is about the assumption underneath all of that being wrong: what stops an attacker who does get in from spending $30,000 before the budget alert refreshes.
Originally published at https://iuriio.com/blog/posts/2026/08/aws-hardening-getting-off-root

