# 用 Granted 把 Windows 上的 AWS 明文金鑰換成 SSO — 小心兩套容易搞混的 AWS 登入

> Granted 與 AWS IAM Identity Center 在 Windows 上把靜態 IAM 金鑰換成短效的 SSO 憑證。傳統的 AWS 登入表單完全不接受這組憑證。

- Source: https://oharu121.com/zh-tw/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, 開發工具

---
**重點摘要**

- 洩漏的 AWS 金鑰不必換成一把新金鑰。Granted 搭配 AWS IAM Identity Center，把靜態的 IAM 密鑰換成瀏覽器發出的短效憑證，磁碟上不會留下任何靜態的值。
- signin.aws.amazon.com 的傳統登入表單，和 IAM Identity Center 的 AWS 存取入口網站，是兩套互不相干的登入系統。Identity Center 的使用者名稱，不管密碼多正確，永遠無法在傳統表單上生效。
- `assume` 不能只是放進 PATH 就好，必須用點號來源執行載入到 shell 設定檔中，因為單純的 `.exe` 無法改動啟動它的那個 shell 的環境變數。直接這樣點號來源執行，會讓選單在每個新終端機都重演一次；把同一行包進 PowerShell 函式，就能延後到真正輸入 `assume` 才執行。
- `AdministratorAccess` 不是無條件的。儲存桶自己的資源型政策裡一條明確的 `Deny`，連管理員身分的工作階段都擋下了刪除操作。
- 用 `[Environment]::SetEnvironmentVariable` 改的 PATH，不會傳到已經在執行中的 shell。在 Windows 上安裝完 CLI 後馬上出現的「命令找不到」，幾乎都是這個原因。

## 引言

在檢查 AWS 設定檔裡一個與正事無關的區域打字錯誤時，代理程式執行了一個把該檔案印到終端機的指令，結果連緊接在下面的存取金鑰 ID 與密鑰一併以明文印了出來，印進一個原本沒有被當作機密處理的工作階段裡。那把金鑰自 2025 年 2 月起就是這個帳戶唯一的憑證：一把靜態的 IAM 使用者密鑰，放在 `~/.aws/credentials`，沒有到期時間，也沒有輪替。我當場決定不再使用它，改用 Granted 這個把 AWS IAM Identity Center 短效 SSO 憑證包裝起來的 CLI，取代一把永久的金鑰。這次遷移最終成功了，但過程中經過了一個看起來像是正確、實際上並不是的登入頁面。本文說明如何在 Windows 上建置 Granted 與 IAM Identity Center，以及真正耗費時間的那個地方：一個絕對不會接受手上這組登入資訊的登入表單。

## 金鑰以明文印了出來

這個帳戶只有一個 AWS CLI 設定檔 `default`，背後是一把靜態存取金鑰，屬於一個當初為了 Next.js 部署管線而建立的 IAM 使用者。為了修正一個與正事無關的 `region = ap-northeast-1c` 打字錯誤（這其實是一個可用區域，不是區域）而讀取 `~/.aws/config` 與 `~/.aws/credentials`，結果把兩個檔案都印到了終端機上。`~/.aws/credentials` 裡完整存放著存取金鑰 ID 與密鑰。

**一把沒有到期時間的長效金鑰，只要在不該出現的地方出現過一次，就成了常設的風險。**聊天記錄正是這樣的地方。這裡正確的做法不是把金鑰輪替一次然後繼續用下去，而是徹底不在這台機器上留下任何靜態金鑰。

## 在 Windows 上安裝 Granted

[Granted](https://granted.dev) 把 AWS IAM Identity Center 的 SSO 流程包裝在兩個指令背後：用來切換角色的 `assume`，以及用於設定與帳戶管理的 `granted`。這樣一來，終端機工作階段持有的就是短效憑證，而不是永久的密鑰。Windows 並不是 Granted 自家文件主要針對的平台，Windows 版安裝過程中有三件事，光看 README 看不出來。

**發布的資產是依架構區分的**，選錯了不會出現清楚的錯誤，只會安靜地失敗。先確認一下，就不用用猜的：

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

結果是 `AMD64`，代表要用 `windows_x86_64` 版本，而不是 `arm64`。解壓縮出來的檔案裡有 `granted.exe`、`assumego.exe`，以及對應每種 shell 的 `assume.ps1`／`assume.bat`／`assume`。把這些全部複製到一個固定的資料夾，再把那個資料夾加進使用者的 `PATH`，這部分算是簡單的。

**`assume` 不能只是以執行檔的身分放在 PATH 上，因為 assume 一個角色，意味著要改動呼叫它的那個 shell 的環境變數**，而子行程永遠沒辦法替父層做到這件事。單純的 `.exe` 是在自己的行程裡執行後就結束，它設定的任何東西都會隨著行程一起消失。`assume.ps1` 必須改成在目前的 shell*裡面*執行，也就是要把它用點號來源執行載入到 PowerShell 設定檔裡，讓每個新的工作階段都自動載入它：

```powershell
. "$env:USERPROFILE\granted\assume.ps1"
```

寫在 `Microsoft.PowerShell_profile.ps1` 裡。如果跳過這一步，只是直接從 PATH 執行 `assume`，指令碼確實會執行，但它匯出的任何憑證都會在子行程結束的瞬間消失，父層 shell 仍然和之前一樣完全未驗證。

*Figure — AssumeSourcing: 單純的 `.exe` 會把憑證設定在一個子行程裡，該子行程結束時這些憑證就被捨棄。點號來源執行則完全去掉了子行程，讓同樣的變數直接落在父層 shell 裡並留在那裡。*

**在某個 shell 裡改動的 `PATH`，不會傳到已經在執行中的 shell。** 透過

```powershell
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")
```

把 Granted 的資料夾加進使用者的 `PATH`，寫入的是登錄檔，而只有每一個*新*的行程在啟動時才會讀取它。對於變動發生當下已經活著的行程，這完全不會有任何作用。每一個已經開著的終端機都持續出現

```
granted.exe : The term 'granted.exe' is not recognized as the name of a cmdlet, function, script file, or operable program.
```

直到那個 shell 從使用者與電腦兩個範圍重新載入 `$env:Path`，或是開一個全新的終端機為止才恢復正常。**同樣的 PATH 陷阱在這次遷移裡又出現了兩次**：一次是同一個工作階段裡稍後重新安裝的 `pnpm`，另一次是在另一種 shell（Bash）裡，同一個工具在 `PATH` 上有一條完全不同、而且各自獨立壞掉的項目。

## 不透過 AWS Organization 建置 IAM Identity Center

Granted 的 SSO 流程需要一個實際存在的 IAM Identity Center 執行個體可以對話，所以在設定任何本機設定檔之前，帳戶必須先啟用 Identity Center。AWS 的文件記載了兩種執行個體類型：需要 AWS Organizations 的組織執行個體，以及不需要、僅限單一獨立帳戶使用的帳戶執行個體。

**啟用畫面上預設的路徑會建立一個組織，這件事在點下任何東西之前，就有值得先知道的計費後果。** AWS 自家文件講得很直白：在免費方案帳戶上，為了容納預設的組織執行個體而建立 AWS Organization，會讓帳戶立刻轉為隨用隨付計費，並讓剩下的免費方案額度失效。畫面上確實有提供另一條路徑，也就是帳戶執行個體，但它只是一個次要連結，而不是那個按鈕。在點任何東西之前先讀文件，讓這件事在變成計費上的意外之前就先被發現，而不是事後才發現。

啟用帳戶執行個體之後，出現了第二個看起來像設定錯誤、實際上不是的地方：主控台顯示**「IAM Identity Center is currently configured in the US East (N. Virginia) Region」**，而且是在還沒有刻意在那裡設定任何東西之前就出現了。這則橫幅只有在該帳戶在那個區域已經存在一個執行個體時才會出現，而帳戶執行個體會永久鎖定在建立時所在的區域；之後要換區域，代表要刪掉這個執行個體，再從零重建每一個使用者、權限集與指派。很容易把這個區域鎖定誤讀成也限制了帳戶實際資源可以在哪裡執行。事實並非如此：**Identity Center 的區域，決定的只是它自己的身分中繼資料放在哪裡**，而不是 EC2 執行個體、S3 儲存桶或帳戶裡任何其他東西可以放在哪裡。從維吉尼亞管理登入系統，同時讓所有真正的資源都留在東京，是很正常的事，也沒有什麼值得一提的延遲成本。

## 看起來像一個的兩套登入

帳戶執行個體建立好之後，設定過程一如預期地繼續進行：建立一個使用者、一個權限集（`AdministratorAccess`），以及一個把兩者連到帳戶的指派。失敗的是用它登入這一步，而且失敗得完全沒有給出任何有用的錯誤。

登入 AWS 最直覺想到的地方是 `signin.aws.amazon.com`，每一篇 AWS 教學和每一個書籤都指向這裡。在這裡輸入帳戶 ID、新建立的 Identity Center 使用者名稱與密碼，每次都被拒絕登入，也完全沒有提示原因。**傳統登入表單與 Identity Center 登入，是兩套毫無共通之處的獨立系統**：沒有共用使用者目錄，沒有共用密碼庫，甚至連「這是同一個帳戶」這個概念都不共用。傳統表單驗證的是在 IAM → Users 底下建立的 IAM 使用者；這個帳戶唯一的 IAM 使用者，是最初的部署使用者 `nextjs-deploy`，Identity Center 的使用者從來不會被登錄在那裡。不管密碼多正確，Identity Center 身分的密碼永遠無法滿足那個表單。

*Figure — TwoLogins: 兩張卡片之間刻意沒有畫一條連線。傳統登入表單與 AWS 存取入口網站驗證的是兩個不同的使用者目錄，所以在其中一邊行得通的東西，在另一邊完全行不通。*

真正行得通的門是 AWS 存取入口網站，要透過帳戶自己的 SSO 起始網址（`https://d-xxxxxxxxxx.awsapps.com/start`，可以在 Identity Center 主控台的 Settings 頁面找到）才能到達。在那裡登入後，會出現一個列出該帳戶與其指派的權限集 `AdministratorAccess` 的頁面，權限集本身是一個可以點的連結。**要點的是權限集的名稱，而不是旁邊那顆小小的鑰匙圖示**，鑰匙圖示只會顯示 CLI 用的憑證。點下權限集名稱，開啟的正是和傳統登入流程一樣完整的 AWS Management Console，只是帶著的是一個暫時的聯合工作階段，而不是永久的 IAM 登入。

## 重複的電子郵件，以及重新建立使用者

第一個 Identity Center 使用者是用一個次要的電子郵件建立的，從來沒有跨過前面說的那個登入問題。在確認問題出在登入頁面而不是憑證本身之後，重設密碼再試了一次，還是無法通過驗證。**與其繼續除錯一個從來沒有成功過的登入，不如刪掉使用者重建看起來更簡單**，於是接下來就這麼做了，這次用的是實際上每天在用的那個電子郵件。

第一次嘗試並不簡單：

```
Errors occurred while adding user "oharu121-dev"
The user couldn't be added
Duplicate unique attribute value for emails.value
```

**Identity Center 在整個目錄範圍內強制每個電子郵件只能對應一個使用者**，而第一個、仍然壞掉的那個使用者還佔著那個地址。先刪掉它，再用同一個電子郵件重建 `oharu121-dev`，這次乾淨地通過了。這次改成透過 AWS 存取入口網站，而不是傳統表單登入，第一次嘗試就成功了，印證了先前的判斷：**這個帳戶從來不是密碼壞掉，而是一直對著錯誤的系統嘗試登入**。

## 把 Granted 接上新的登入

有了一個能用的 Identity Center 使用者之後，產生本機 AWS CLI 設定檔只需要一個指令：

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

它會開啟瀏覽器進行一次性的裝置授權，並寫入一個使用 Granted 自己的憑證解析器、而不是靜態金鑰的設定檔：

```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`，確認它可以運作：

```json
{
    "UserId": "AROAR6BADHEROZFPENFQ5:oharu121-dev",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_.../oharu121-dev"
}
```

得到的是一個 `assumed-role` 的 ARN，而不是舊有的 `arn:aws:iam::...:user/nextjs-deploy`。

**`assume` 沒辦法從非互動式的 shell 驅動，就算把確切的設定檔名稱當成參數傳進去也一樣。** 不管怎麼樣它都會開一個方向鍵選單來確認選擇，而那個選單在指令碼或工具驅動的工作階段裡，沒有任何主控台輸入可以讀：

```
? Please select the profile you would like to assume:  [Use arrows to move, type to filter]
[✘] Incorrect function.
```

因為這個原因，這次遷移裡每一次 `assume` 呼叫都必須在真正有人盯著的終端機裡執行。**`credential_process` 在底層的 SSO token 快取好之後，設計上就是非互動式的**，所以單純的 `aws` 指令不管有沒有人盯著，到哪裡都能用。把 `[default]` 設定檔指向同一個解析器，就不必每個指令都輸入 `--profile` 了：

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

## 每次開新終端機都會重演選單

光是開一個普通的 PowerShell 視窗，還沒輸入任何指令，每次都會直接掉進互動式選單：

```
? Please select the profile you would like to assume:  [Use arrows to move, type to filter]

> oharu121/AdministratorAccess
  default
[✘] interrupt
```

**原因就是本文稍早建議寫進去的那一行設定檔內容。** 代理程式重新讀了一次 `Microsoft.PowerShell_profile.ps1`，發現的正是前面已經寫過的那一行：

```powershell
. "$env:USERPROFILE\granted\assume.ps1"
```

`assume.ps1` 從來沒有把自己的邏輯包進一個函式裡，而是在那行點號來源執行的當下，直接執行 `assumego`：

```powershell
$ASSUME_FLAG, $ASSUME_1, ... = `
$(& (Join-Path $PSScriptRoot -ChildPath "assumego") $args) -split '\s+'
```

而 `assumego --help` 記載的用法是 `assume [options][Profile]`：沒有設定檔參數就會退回互動式選單，而空的 `$args` 正好就是這個狀態。**把指令碼直接點號來源執行進設定檔，代表這一行會在每次 shell 啟動時立刻執行，而且沒有設定檔參數可以傳，所以選單不只在第一次，而是在每一個終端機都會跳出來。**

修正的做法保留了點號來源執行（單純的 `.exe` 依然無法改動父層 shell 的環境變數），但把它延後包進一個函式裡，讓這一行只在真正輸入 `assume` 的時候才執行：

```powershell
function assume { . "$env:USERPROFILE\granted\assume.ps1" @args }
```

**只定義這個函式的設定檔，在啟動時什麼都不會執行。** 之後執行 `assume oharu121/AdministratorAccess`，運作方式和之前完全一樣：`@args` 展開會在需要的當下才把設定檔名稱一路帶到 `assumego`，而不是每次 shell 啟動就先跑一次。

## 驗證，以及管理員權限帶來的收穫

不加 `--profile` 旗標執行 `aws sts get-caller-identity`，確認 `default` 設定檔現在會透過 SSO 解析。接下來要做的是徹底淘汰那把靜態金鑰：在確認 `.github/workflows`、AWS SDK，以及金鑰 ID 本身可能出現的每個地方，這個部落格儲存庫的 CI 設定與原始碼裡都沒有任何對它的參照之後，對 `nextjs-deploy` 使用者執行了 `aws iam delete-access-key`。**儲存庫裡完全沒有任何地方參照那把金鑰**，這表示這個部落格是透過託管服務自己的 git 整合部署的，而不是透過那把金鑰。

這個管理員工作階段，也讓稽核帳戶裡是否還有其他東西在跑成為可能。**沒有正在執行的運算資源，也沒有作用中的 Elastic Beanstalk 環境**，只剩下兩個小小的孤立 S3 儲存桶（各只有幾 KB，是早就被放棄的專案留下的部署產物），以及兩筆底下什麼都沒部署的空 Elastic Beanstalk「Application」紀錄。

刪除這些儲存桶時，浮現出 `AdministratorAccess` 唯一一個並非真正管理員權限的地方：

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

這個儲存桶是 Elastic Beanstalk 自動建立的，帶著自己的一份資源型政策，裡面有一條 AWS 預設附加的陳述式：

```json
{
  "Sid": "eb-58950a8c-feb6-11e2-89e0-0800277d041b",
  "Effect": "Deny",
  "Principal": { "AWS": "*" },
  "Action": "s3:DeleteBucket",
  "Resource": "arn:aws:s3:::elasticbeanstalk-..."
}
```

**明確的 `Deny` 在 AWS 的政策評估中會凌駕於每一條 `Allow` 之上，包含萬用字元的 `AdministratorAccess` 權限集也不例外**，因為它是寫給任何人看的，而不是針對某個管理員權限也贏不過的特定身分。先刪掉儲存桶自己的資源型政策，再刪除儲存桶，就解決了。`AdministratorAccess` 的意思是沒有*身分型*的政策會擋路，但它完全沒有說資源自己的政策不會擋住這個資源自己。

## 總結

**這個帳戶最終歸零了所有靜態 AWS 金鑰**：舊 IAM 使用者的存取金鑰已從 IAM 刪除，`~/.aws/credentials` 已清空，每一個 `aws` 指令現在預設都透過 Granted 的 `credential_process` 解析到 IAM Identity Center 的 SSO。走到這一步，經過了安裝任何東西之前的架構確認、一個單純的 PATH 項目無法取代的 PowerShell 設定檔編輯、一個避開了不想要的 AWS Organization 的帳戶執行個體選擇，以及一次和密碼錯誤完全無關的登入失敗。傳統 AWS 登入表單與 Identity Center 存取入口網站，看起來像是通往同一間房子的兩扇門。**但它們其實是兩棟不同的房子，而其中只有一棟聽過 Identity Center 使用者這回事。**

## 參考連結

- [Granted：存取雲端最簡單的方式](https://granted.dev)
- [IAM Identity Center 的組織執行個體與帳戶執行個體，含獨立帳戶免費方案的計費說明](https://docs.aws.amazon.com/singlesignon/latest/userguide/organization-instances-identity-center.html)
- [在 IAM Identity Center 切換 AWS 區域，以及既有執行個體為何無法在不刪除的情況下搬遷](https://docs.aws.amazon.com/singlesignon/latest/userguide/switching-regions.html)
