# 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.

- Source: https://oharu121.com/blog/granted-windows-plaintext-aws-key-iam-identity-center-sso/
- Published: 2026-08-22T11:19:22+09:00
- Updated: 2026-08-23T06:47:48+09:00
- Tags: AWS, Granted, Developer Tooling

---
**Key takeaways**

- A leaked AWS key does not have to become a new key. Granted plus AWS IAM Identity Center replaces a static IAM secret with short-lived, browser-issued credentials, and nothing static is left on disk.
- The classic sign-in form at signin.aws.amazon.com and the IAM Identity Center access portal are two unrelated login systems. An Identity Center username will never work on the classic form, no matter how correct the password is.
- `assume` has to be dot-sourced into the shell profile, not just placed on PATH, because a plain `.exe` cannot mutate the environment variables of the shell that launched it. Dot-sourcing it bare replays the profile picker on every new terminal; wrapping the same line in a PowerShell function defers it until `assume` is actually typed.
- `AdministratorAccess` is not unconditional. An explicit `Deny` in a bucket's own resource policy blocked even an admin session from deleting it.
- A PATH change made with `[Environment]::SetEnvironmentVariable` does not reach a shell that is already running. Every "command not found" right after installing a CLI on Windows traces back to this.

## 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](https://granted.dev) 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:

```powershell
echo $env:PROCESSOR_ARCHITECTURE
```

returned `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:

```powershell
. "$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.

*Figure — AssumeSourcing: A plain `.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

```powershell
[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.

*Figure — TwoLogins: No line connects the two cards on purpose. The classic sign-in form and the AWS access portal authenticate two different user directories, so nothing that works on one side works on the other.*

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 added
Duplicate unique attribute value for emails.value
```

**Identity 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:

```powershell
granted sso populate --sso-region us-east-1 https://d-xxxxxxxxxx.awsapps.com/start
```

which opened a browser for a one-time device authorization and wrote a profile using Granted's own credential resolver instead of a static key:

```ini title=".aws/config"
[profile oharu121/AdministratorAccess]
granted_sso_start_url      = https://d-xxxxxxxxxx.awsapps.com/start
granted_sso_region         = us-east-1
granted_sso_account_id     = 123456789012
granted_sso_role_name      = AdministratorAccess
credential_process         = granted credential-process --profile oharu121/AdministratorAccess
```

`assume oharu121/AdministratorAccess` in a real terminal window confirmed it worked:

```json
{
    "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:

```ini title=".aws/config"
[default]
region = ap-northeast-1
output = json
credential_process = granted credential-process --profile oharu121/AdministratorAccess
```

## Every 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
[✘] interrupt
```

**The 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:

```powershell
. "$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:

```powershell
$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:

```powershell
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-dev
is not authorized to perform: s3:DeleteBucket on resource: "arn:aws:s3:::elasticbeanstalk-..."
with an explicit deny in a resource-based policy
```

The bucket, created automatically by Elastic Beanstalk, carried its own policy with a statement AWS attaches by default:

```json
{
  "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.**

## References

- [Granted: the easiest way to access your cloud](https://granted.dev)
- [Organization and account instances of IAM Identity Center, including the standalone-account free-tier billing note](https://docs.aws.amazon.com/singlesignon/latest/userguide/organization-instances-identity-center.html)
- [Switching AWS Regions in IAM Identity Center, and why an existing instance cannot move without deletion](https://docs.aws.amazon.com/singlesignon/latest/userguide/switching-regions.html)
