
Granted on Windows swaps a plaintext AWS key for SSO — and be aware of the two AWS logins
Granted and AWS IAM Identity Center replace a static IAM key with short-lived SSO credentials on Windows. The classic AWS sign-in form never accepts it.
On this page
Introduction
While checking an unrelated region typo in an AWS config file, the agent ran a command to print it to the terminal and printed the paired access key ID and secret sitting right below it, in plaintext, into a session that had not been treated as sensitive. That key had been the account’s only credential since February 2025: one static IAM user secret, sitting in ~/.aws/credentials, with no expiry and no rotation. I decided on the spot to stop using it and migrate to Granted, a CLI that wraps AWS IAM Identity Center’s short-lived SSO credentials instead of a permanent key. The migration worked, but it went through a login page that looks like the right one and is not. This article covers standing up Granted and IAM Identity Center on Windows, and the specific place that cost real time: a sign-in form that will never accept the login it was handed.
The key came out in plain text
The account had a single AWS CLI profile, default, backed by a static access key belonging to an IAM user named for its original purpose, a Next.js deploy pipeline. Reading ~/.aws/config and ~/.aws/credentials to fix an unrelated region = ap-northeast-1c typo (an availability zone, not a region) meant printing both files to the terminal. ~/.aws/credentials held the access key ID and secret access key in full.
A long-lived key with no expiry is a standing liability the moment it is visible anywhere it should not be, and a chat transcript is exactly that. The fix was not to rotate the key and keep going. It was to stop having a static key on the machine at all.
Installing Granted on Windows
Granted wraps AWS IAM Identity Center’s SSO flow behind two commands, assume to switch roles and granted for setup and account management, so a terminal session holds short-lived credentials instead of a permanent secret. Windows was not the primary platform Granted’s own documentation was written for, and three things about the Windows install were not obvious from the README.
The release asset is architecture-specific, and the wrong pick fails silently rather than with a clear error. Checking first avoided guessing:
echo $env:PROCESSOR_ARCHITECTUREreturned AMD64, meaning the windows_x86_64 build, not arm64. The extracted archive contains granted.exe, assumego.exe, and a matching assume.ps1/assume.bat/assume per shell. Copying all of it into a permanent folder and adding that folder to the user PATH was the easy part.
assume cannot just live on PATH as an executable, because assuming a role means changing environment variables in the calling shell, and a child process can never do that for its parent. A plain .exe runs in its own process and exits; whatever it sets is gone with it. assume.ps1 has to run inside the current shell instead, which means dot-sourcing it into the PowerShell profile so every new session picks it up automatically:
. "$env:USERPROFILE\granted\assume.ps1"placed in Microsoft.PowerShell_profile.ps1. Skipping this and just running assume from PATH does execute the script, but any credentials it exports vanish the moment the child process exits, leaving the parent shell exactly as unauthenticated as before.
.exe sets its credentials inside a child process that discards them on exit. Dot-sourcing removes the child process entirely, so the same variables land in the parent shell and stay there.A PATH change made in one shell does not reach a shell that is already running. Adding the Granted folder to the user PATH via
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")writes to the registry, which every new process reads at launch. It does nothing for a process already alive when the change happened. Every already-open terminal kept failing with
granted.exe : The term 'granted.exe' is not recognized as the name of a cmdlet, function, script file, or operable program.until either that shell reloaded $env:Path from both the user and machine scopes, or a fresh terminal was opened. The same PATH trap resurfaced twice more in this migration: once for pnpm, reinstalled later in the same session, and once for a second shell type (Bash) whose PATH had a completely different, and separately broken, entry for the same tool.
Standing up IAM Identity Center without an AWS Organization
Granted’s SSO flow needs an actual IAM Identity Center instance to talk to, so before wiring up any local profile the account needed Identity Center enabled. AWS documents two instance types: an organization instance, which requires AWS Organizations, and an account instance, which does not and is scoped to a single standalone account.
The default path on the enable screen creates an organization, and that has a billing consequence worth knowing before clicking anything. AWS’s own documentation states it plainly: on a free-tier account, creating an AWS Organization to host the default organization instance immediately converts the account to pay-as-you-go pricing and expires any remaining free-tier credits. The screen does offer the other path, an account instance, but as a secondary link rather than the button. Reading the docs before clicking anything caught this before it became a billing surprise rather than after.
Enabling the account instance produced a second piece of friction that looked like a misconfiguration and was not: the console showed “IAM Identity Center is currently configured in the US East (N. Virginia) Region” even before anything had knowingly been set up there. That banner only appears when an instance already exists in that region for the account, and an account instance is permanently locked to whichever region it was created in; changing regions later means deleting the instance and rebuilding every user, permission set, and assignment from scratch. It is easy to read that region lock as also constraining where the account’s actual resources can run. It does not: Identity Center’s region only decides where its own identity metadata lives, not where EC2 instances, S3 buckets, or anything else in the account is allowed to be. Administering the login system from Virginia while every real resource stays in Tokyo is normal and has no latency cost worth mentioning.
Two logins that look like one
With the account instance up, the setup continued as expected: a user, a permission set (AdministratorAccess), and an assignment linking the two to the account. What failed was logging in with it, and it failed in a way that produced no useful error at all.
The obvious place to sign in to AWS is signin.aws.amazon.com, the page every AWS tutorial and bookmark points at. Entering the account ID, the new Identity Center username, and its password there rejected the login every time, with no hint about why. The classic sign-in form and the Identity Center login are two separate systems that share nothing, not a user directory, not a password store, not even the concept of a matching account. The classic form authenticates IAM users created under IAM → Users; the only IAM user this account had was the original deploy user, nextjs-deploy, and Identity Center users are never enrolled there. No password for an Identity Center identity will ever satisfy that form, correct or not.
The door that actually works is the AWS access portal, reached at the account’s own SSO start URL (https://d-xxxxxxxxxx.awsapps.com/start, found on the Identity Center console’s Settings page). Logging in there produced a page listing the account and its assigned permission set, AdministratorAccess, as a clickable link. Clicking the permission set name, not the small key icon beside it, which only exposes CLI credentials, opens the full AWS Management Console exactly as the classic sign-in flow would have, just carrying a temporary federated session instead of a persistent IAM login.
The duplicate email, and starting the user over
The first Identity Center user, created with a secondary email, never got past the login problem above. Once that was understood to be the login page and not the credentials, a second attempt with a password reset still failed to authenticate. Deleting and recreating the user looked simpler than continuing to debug a login that had never worked, so that is what happened next, with the email actually used day to day.
It was not simpler on the first try:
Errors occurred while adding user "oharu121-dev"The user couldn't be addedDuplicate unique attribute value for emails.valueIdentity Center enforces one user per email across its directory, and the first, still-broken user still held that address. Deleting it first, then recreating oharu121-dev with the same email, went through cleanly. Logging in through the AWS access portal, not the classic form this time, worked on the first attempt, which confirmed the earlier diagnosis: the account had never had a working password so much as a login attempted on the wrong system.
Wiring Granted to the new login
With a working Identity Center user, generating a local AWS CLI profile was one command:
granted sso populate --sso-region us-east-1 https://d-xxxxxxxxxx.awsapps.com/startwhich opened a browser for a one-time device authorization and wrote a profile using Granted’s own credential resolver instead of a static key:
[profile oharu121/AdministratorAccess]granted_sso_start_url = https://d-xxxxxxxxxx.awsapps.com/startgranted_sso_region = us-east-1granted_sso_account_id = 123456789012granted_sso_role_name = AdministratorAccesscredential_process = granted credential-process --profile oharu121/AdministratorAccessassume oharu121/AdministratorAccess in a real terminal window confirmed it worked:
{ "UserId": "AROAR6BADHEROZFPENFQ5:oharu121-dev", "Account": "123456789012", "Arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_.../oharu121-dev"}an assumed-role ARN instead of the old arn:aws:iam::...:user/nextjs-deploy.
assume cannot be driven from a non-interactive shell, even with an exact profile name as an argument. It opens an arrow-key picker to confirm the choice regardless, and that picker has no console input to read from a scripted or tool-driven session:
? Please select the profile you would like to assume: [Use arrows to move, type to filter][✘] Incorrect function.Every assume call in this migration had to run in a real, human-attended terminal for that reason. credential_process is non-interactive by design once the underlying SSO token is cached, so plain aws commands work anywhere, unattended terminal or not. Pointing the [default] profile at the same resolver removed the need to type --profile for every command:
[default]region = ap-northeast-1output = jsoncredential_process = granted credential-process --profile oharu121/AdministratorAccessEvery new terminal replayed the profile picker
Opening a plain PowerShell window started dropping into the interactive picker before any command had been typed, every single time:
? Please select the profile you would like to assume: [Use arrows to move, type to filter]
> oharu121/AdministratorAccess default[✘] interruptThe profile line this article recommended earlier turned out to be the cause. The agent reread Microsoft.PowerShell_profile.ps1 and found the same line already documented above:
. "$env:USERPROFILE\granted\assume.ps1"assume.ps1 never wraps its logic in a function. It runs assumego directly, on the line that dot-sources it:
$ASSUME_FLAG, $ASSUME_1, ... = `$(& (Join-Path $PSScriptRoot -ChildPath "assumego") $args) -split '\s+'and assumego --help documents the usage as assume [options][Profile]: no profile argument falls back to the interactive picker, which is exactly what an empty $args produces. Dot-sourcing the script straight into the profile runs that line immediately at every shell start, with no profile argument to pass it, so the picker fired on every terminal rather than only the first.
The fix keeps dot-sourcing (a plain .exe still cannot mutate the parent shell’s environment) but defers it behind a function, so the line only executes once assume is actually typed:
function assume { . "$env:USERPROFILE\granted\assume.ps1" @args }A profile that only defines this function runs nothing at startup. Calling assume oharu121/AdministratorAccess afterward still worked exactly as before: the @args splat carries the profile name through to assumego on demand, instead of at every shell start.
Verification, and what admin access was worth
aws sts get-caller-identity with no --profile flag confirmed the default profile now resolved through SSO. The next step was retiring the static key for good: aws iam delete-access-key against the nextjs-deploy user, after checking the blog repository’s own CI configuration and source for any reference to it. Nothing in the repository referenced that key, in .github/workflows or anywhere the AWS SDK or the key ID itself might have shown up, which meant this blog deploys through its host’s own git integration, not through it.
The admin session also made it possible to audit the account for anything else still running. No running compute and no active Elastic Beanstalk environments remained, only two small orphaned S3 buckets (a few kilobytes each, deployment artifacts from long-abandoned projects) and two empty Elastic Beanstalk “Application” records with nothing deployed under them.
Deleting the buckets surfaced the one place AdministratorAccess was not actually administrator access:
An error occurred (AccessDenied) when calling the DeleteBucket operation:User: arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_.../oharu121-devis not authorized to perform: s3:DeleteBucket on resource: "arn:aws:s3:::elasticbeanstalk-..."with an explicit deny in a resource-based policyThe bucket, created automatically by Elastic Beanstalk, carried its own policy with a statement AWS attaches by default:
{ "Sid": "eb-58950a8c-feb6-11e2-89e0-0800277d041b", "Effect": "Deny", "Principal": { "AWS": "*" }, "Action": "s3:DeleteBucket", "Resource": "arn:aws:s3:::elasticbeanstalk-..."}An explicit Deny overrides every Allow in AWS’s policy evaluation, including a wildcard AdministratorAccess permission set, because it is written for anyone, not for a specific identity that admin access could out-rank. Deleting the bucket’s own policy first, then deleting the bucket, cleared it. AdministratorAccess means no identity-based policy stands in the way; it says nothing about a resource’s own policy standing in the way of itself.
Summary
The account ended with zero static AWS keys: the old IAM user’s access key deleted from IAM, ~/.aws/credentials emptied, and every aws command resolving through Granted’s credential_process against IAM Identity Center SSO by default. Getting there took an architecture check before installing anything, a PowerShell profile edit that a plain PATH entry could not substitute for, an account-instance choice that avoided an unwanted AWS Organization, and a login failure that had nothing to do with a wrong password. The classic AWS sign-in form and the Identity Center access portal look like two doors into the same house. They are two different houses, and only one of them has ever heard of an Identity Center user.



