Skip to content

AWS access

What you get

  • Agents default to read-only in the prod account, while admin stays available to you when you need it. Separate SSO sessions, so an agent never picks up an admin token by accident. When the nonprod account lands, agents get full admin there — a sandbox for lower environments.
  • Google SSO to log in, with users fed from Google Workspace over the SCIM API. No separate credential to hold or rotate.
  • Never the Duplo console again.onramp-ai.awsapps.com/start for console access, and the config below for the CLI and agents.
  • 12h sessions for the read-only agent profile, 8h for admin.
  • Paves the way for Claude Code cloud sessions to reach AWS without anyone stashing a credential.

Getting started

In Claude Code, once per machine:

/setup-aws

It writes the config, walks you through the browser login, and confirms which identity you ended up with. Reach for the same skill when AWS stops working — it handles re-login, a config the CLI can't read, and the region trap.

Day to day, read-only takes no command at all — for calls that resolve [default], meaning no --profile and no AWS_PROFILE. When the session lapses, the next such call opens the consent flow itself; approve it in the browser and the call proceeds. Naming --profile readonly bypasses the helper and still fails on an expired token, so log in explicitly for that, or to get it out of the way early:

bash
aws sso login --profile readonly

In a remote or containerised session there is no local browser for the helper to open, so log in up front instead of waiting for it to try and give up:

bash
aws sso login --profile readonly --use-device-code

Break-glass is deliberate, and stays a command you type:

bash
AWS_PROFILE=admin aws sso login

A bare aws sso login is not one of these. [default] carries no sso_session for it to read — it delegates instead (see Long-lived processes and rotating refresh tokens) — and the delegation is what makes the command unnecessary.

Setup details

Without a clone — anyone with GitHub read on this repo, no gh checkout needed:

bash
gh api repos/OnRampTech/main-web-application/contents/devtools/aws-config-setup.sh --jq .content | base64 -d | bash

The script needs the AWS CLI v2 and stops with the install command if it's missing. It backs up any existing ~/.aws/config (keeping the newest three backups), replaces only the sections it owns, and preserves every other section verbatim — unless the merged file will not parse, in which case it writes the managed profiles alone and leaves your original in the backup it names, which is never pruned. After writing it confirms the CLI can actually parse the result, rather than reporting success blind. It's idempotent — re-run it whenever this doc changes.

Using admin

The admin login is a separate SSO session with its own token cache, so logging in there grants nothing to anything running as default or readonly.

Login and use are separate steps, so prefix the commands too — after the admin login, plain aws is still read-only:

bash
AWS_PROFILE=admin aws s3 ls

Never export AWS_PROFILE=admin

The inline prefix scopes the variable to one command. An exported profile persists for the shell and elevates every process started from it, including agents. The same applies to anything that exports AWS_ACCESS_KEY_ID / AWS_SESSION_TOKEN into your shell: static credentials in the environment silently beat both AWS_PROFILE and ~/.aws/config, which are not even consulted. aws configure list names the source of each value, which is the fastest way to see which layer actually won.

Profiles

Three, all on account 111776553558.

ProfileRoleUseHolds a token
defaultAgent-ReadOnlyanything without --profile, including agentsno — delegates
readonlyAgent-ReadOnlythe same identity, named explicitlyyes
adminAdministratorAccessbreak-glass, and operator tools that only writeyes

default is the one that delegates, because it is the one long-running processes resolve — see Long-lived processes and rotating refresh tokens. It names readonly, so the role and account are declared once and move in one place.

Tools that ask for admin themselves

An operator tool whose every AWS call is a write may name admin on each aws invocation rather than leaving the profile ambient — bun run pipeline (devtools/pipeline-cutover/aws.ts) is the one that does today. Left ambient it would land on a read-only profile, and the refusal would arrive after its confirmation gate: consent spent on a sequence that could never have run.

This is the inline prefix the warning above endorses, not the export it forbids — --profile on one command elevates that command and nothing else, so nothing the tool spawns inherits admin. An AWS_PROFILE you set yourself still wins, which is what makes a deliberate read-only dry run possible.

The trade is the rotation one named below: these are interactive tools, so a long enough session can outlive the admin token. The failure is a loud auth error naming the login to run, never a silent downgrade.

admin holds its own sso_session instead, which is what makes AWS_PROFILE=admin aws sso login work. The trade is that a process staying up for hours while resolving admin can hit the rotation problem the delegation exists to prevent. Nothing is supposed to be in that position: agents and MCP servers resolve default, and the rule below is never to export the admin profile into a shell that outlives the command.

What the split does not stop

Separate sessions mean separate consent flows and separate token caches. That prevents an agent from inheriting admin. It does not prevent one from choosing it.

ClaimHolds?Why
Logging into admin grants nothing to default / readonlyYesDifferent cache file, never opened by the agent session
An agent pinned to default gets read-onlyYesThe profile names the role; that's what gets requested
An agent cannot use AdministratorAccessNo--profile admin is one flag, and ~/.aws/config is readable by anything running as you

The only real friction is the consent flow: an agent can't mint an admin token on its own, because approval needs a human at a browser or device code. With no admin token cached, --profile admin fails with an expired-or-invalid-session error.

That friction is unchanged by auto-consent, which covers readonly only — an agent can open a read-only consent prompt, and a human still has to approve it. There is no path by which an agent approves anything, or by which a read-only approval yields an admin token.

So the exposure window is exactly the lifetime of a cached admin token. In the hour after break-glass work, any process running as you — including an agent nominally pinned to read-only — can pick up AdministratorAccess by naming the profile.

aws sso logout is global and would take your agent session down too. To drop only admin:

bash
rm ~/.aws/sso/cache/75f8e18b36ab17a962b3b07c6b25707e70ecd71a.json

That filename is sha1("onramp-admin") and is stable, so it's safe to alias. The agent session's token is a different file and is untouched.

File layout is a safety rail, not a security boundary

It stops the accident — inherited tokens, an agent silently running elevated because someone exported a profile. It cannot stop a deliberate flag, or an injected instruction using one. The enforcement that holds is the permission set itself, on the Identity Center side.

sso_region and region are different on purpose

sso_region = us-east-2 is where the Identity Center directory lives. region = us-west-2 is where API calls go, because that's where our resources are — infra/ci-identity/envs.ts sets DEFAULT_REGION = "us-west-2" and only gdpr-prod deviates to eu-west-1.

Nothing we deploy lives in us-east-2. Don't "fix" the mismatch: a blind s/us-east-2/us-west-2/ rewrites sso_region too and breaks login.

For EU work pass --region eu-west-1; for CloudFront and its ACM certs, AWS forces --region us-east-1.

Verify

bash
aws sts get-caller-identity --query Arn --output text

The ARN names the permission set — AWSReservedSSO_Agent-ReadOnly_… or AWSReservedSSO_AdministratorAccess_…. That's the only reliable answer to "what am I right now"; the profile name isn't, since environment credentials override it silently.

To confirm the read-only boundary actually holds, run a dry-run write. It performs the authorization check and stops before anything is created, so there's nothing to clean up:

bash
aws ec2 create-security-group --dry-run --group-name perm-probe --description perm-probe --profile readonly
ProfileExpected
readonlyUnauthorizedOperation … not authorized to perform: ec2:CreateSecurityGroup
adminDryRunOperation: Request would have succeeded, but DryRun flag is set.

As admin you can decode the Encoded authorization failure message blob with aws sts decode-authorization-message.

Where tokens live

Each SSO session caches its token under a SHA-1 of the session name:

~/.aws/sso/cache/<sha1(session_name)>.json

Two session names means two files, which is the isolation. Derived STS credentials land separately in ~/.aws/cli/cache/.

Two asymmetries worth knowing:

  • aws sso logout is global. It clears every cached SSO token and all derived credentials across all profiles — there's no per-session scope.
  • The access token is short-lived and refreshes silently. How often you re-authenticate interactively is set by the Identity Center session duration, not the token's own expiry.

Long-lived processes and rotating refresh tokens

Refresh tokens rotate: each refresh returns a new one and retires the token that was used. A short-lived aws invocation is unaffected — it reads the cache, refreshes, writes the new token back, exits. A process that stays up for hours is not. It reads the cache once at startup and holds that refresh token in memory, so every CLI run in the meantime rotates its copy out from under it, and the first time it needs to refresh, it can't — permanently, until it restarts.

MCP servers are the case that bites, because they run for a whole working day. The symptom is a tool failing with Token has expired and refresh failed while aws sts get-caller-identity in the same terminal succeeds, which reads as an AWS problem and isn't one.

The fix is to stop long-lived processes from holding a refresh token at all. [default] — the profile they resolve — carries no SSO config, only a credential_process that shells out to a fresh process which does the refresh and exits:

ini
[default]
credential_process = "/Users/you/.aws/onramp-sso-credentials" readonly
region = us-west-2

[profile readonly]
sso_session = onramp-agent
sso_account_id = 111776553558
sso_role_name = Agent-ReadOnly
region = us-west-2

The path is absolute and quoted for two independent reasons: a GUI- or launchd-launched process inherits a minimal PATH and would not find it otherwise, and botocore shlex-splits this value, so an unquoted path containing a space resolves to its first word. The tradeoff is that three paths are frozen at setup: the helper named in the config, and the AWS_BIN= and TIMEOUT_BIN= lines inside it. Move or reinstall any of them and [default] stops resolving until bun run aws.setup re-runs. ./devtools/aws-config-setup.sh --print shows the config block for your machine; the two baked paths are the helper's own first lines.

A delegating profile must never name itself, or the helper recurses; it refuses to rather than looping. Same shape infra/README.md uses for Duplo JIT credentials, for the same reason.

Why a helper and not export-credentials directly

aws configure export-credentials reports an expired token, it does not renew one. Pointed straight at it, [default] fails with Error when retrieving token from sso: Token has expired and refresh failed — accurate, and useless to whatever was mid-command.

~/.aws/onramp-sso-credentials, written by bun run aws.setup beside the config, closes that: it exports from readonly, and if the token is missing or expired it runs the login first, so the original call proceeds after you approve in the browser. What keeps that safe to trigger from anywhere, agents included:

GuardWhy
stdout is the credential JSON and nothing elsecredential_process parses stdout; login chatter there would break it
only a token error triggers a logina role you are not assigned, a renamed permission set or a dead network fails the export too, and a login fixes none of them. Those are reported as-is, once — not answered with a browser tab per call
one login at a time, via an atomic mkdir lock in ~/.awsten concurrent callers get one browser tab, not ten. flock doesn't exist on macOS; ~/.aws rather than /tmp so the lock is always one you own, and a stale one is reaped by rename so two callers can't both claim it
a 120 s cap on the loginan unattended run fails with a readable message instead of blocking on a tab nobody will click. timeout, gtimeout or perl's alarm, whichever setup found — setup refuses to write the helper if none is present
readonly onlyadmin never auto-consents; break-glass stays something you type
a local browser is assumedheadless or remote, run aws sso login --profile readonly --use-device-code up front

bun run aws.setup writes the config and the helper together, so this is what you get on a fresh machine and what a re-run restores.

Internal documentation — gated behind Cloudflare Access.