一. 前言:內部工具長大以後,第一個問題是「誰可以看?」 #
Streamlit 很適合做內部工具。 資料查詢、模型 demo、營運 dashboard、標註介面,幾十行 Python 就能跑起來。 然後問題就來了。 這個頁面能不能只給某些人看? debug 面板是不是不該公開? 刪除資料的按鈕是不是只有 admin 可以按? 這篇要做的不是把 Streamlit 變成大型 Web framework。 我們要做比較務實的三件事:
- 用
st.login()、st.user、st.logout()做登入。 - 用
st.session_state管理登入後的 UI 狀態。 - 用簡單角色規則控制頁面與操作。
如果你還不熟
session_state,可以先看:
- Streamlit 進階:session_state、cache 與多頁 Dashboard
這篇的核心觀念很簡單: 身份、狀態、權限,不要混在一起。
st.user回答「你是誰」。st.session_state回答「這個瀏覽器 session 現在做到哪一步」。 role / permission 回答「你可以做什麼」。 分開以後,程式會乾淨很多。
二. 安裝與範例目標 #
先建立專案:
uv init streamlit-auth-demo
cd streamlit-auth-demo
uv add streamlit
不用 uv 也可以:
python -m venv .venv
source .venv/bin/activate
pip install streamlit
這篇會做一個小型團隊 dashboard。 它有三個頁面:
- Home:登入後所有人可看。
- Reports:
viewer、editor、admin可看。 - Admin:只有
admin可看。 專案結構:
streamlit-auth-demo/
├── .streamlit/
│ └── secrets.toml
├── app.py
├── auth.py
└── pages_app/
├── home.py
├── reports.py
└── admin.py
這裡刻意用 pages_app/,不是傳統的 pages/。 因為我們要用 st.navigation() 依權限動態決定頁面。
三. session_state 不是登入系統 #
很多 Streamlit 登入範例會長這樣:
import streamlit as st
password = st.text_input("Password", type="password")
if password == "pypy-secret":
st.session_state["logged_in"] = True
if not st.session_state.get("logged_in"):
st.stop()
st.write("Welcome!")
本機 demo 可以。 正式內部工具就不太夠。 問題是:
- 密碼可能寫死在程式碼。
- 沒有 identity provider。
- 沒有可靠使用者資訊。
- 沒有標準登出流程。
- 權限判斷容易散在各頁。
st.session_state很適合放互動狀態: - 篩選器目前值。
- 表單是否展開。
- wizard 走到第幾步。
- 暫存的 UI 選項。 但它不該是身份系統本身。 比較好的分工是:
身份驗證:st.login / st.user / st.logout
角色資料:allowlist、資料庫、群組同步結果
畫面狀態:st.session_state
四. 設定 OIDC:用 secrets.toml 放登入設定 #
Streamlit 內建 auth 走的是 OpenID Connect,也就是 OIDC。 常見 provider 包含 Google Identity、Microsoft Entra ID、Okta、Auth0。 先建立 .streamlit/secrets.toml:
[auth]
redirect_uri = "http://localhost:8501/oauth2callback"
cookie_secret = "replace-with-a-long-random-string"
client_id = "your-client-id"
client_secret = "your-client-secret"
server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"
正式值要到你的 identity provider 後台建立 app 後取得。 幾個重點:
redirect_uri要和 provider 後台一致。cookie_secret要夠長、夠隨機。secrets.toml不要 commit 到 Git。- 部署平台上的 secrets 要另外設定。 如果你還沒整理過 secrets,可以看:
- Streamlit 部署實戰:Secrets、設定檔與雲端上線完整攻略 部署後通常會改成:
[auth]
redirect_uri = "https://your-app.example.com/oauth2callback"
cookie_secret = "replace-with-production-secret"
client_id = "your-production-client-id"
client_secret = "your-production-client-secret"
server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"
不要把範例 secret 原封不動拿去用。 拍拍君會皺眉。
五. 第一個登入牆:st.login、st.user、st.logout #
先寫最小版 app.py。
import streamlit as st
st.set_page_config(page_title="Team Dashboard", layout="wide")
if not st.user.is_logged_in:
st.title("Team Dashboard")
st.write("請先登入,才能使用內部工具。")
if st.button("Log in"):
st.login()
st.stop()
st.sidebar.write(f"Hi, {st.user.get('name', 'teammate')}")
if st.sidebar.button("Log out"):
st.logout()
st.title("Welcome")
st.write("你已經登入。")
st.json(dict(st.user))
跑起來:
uv run streamlit run app.py
流程是:
- 未登入時顯示登入按鈕。
- 按下後
st.login()導向 identity provider。 - 登入完成後回到 app。
st.user會包含使用者資訊。st.logout()清掉這個 app 的 identity cookie。 登入後你可能會看到類似資料:
{
"is_logged_in": True,
"email": "pypy@example.com",
"name": "Pypy User",
"sub": "provider-specific-user-id",
}
不同 provider 給的 claim 可能不同。 正式程式請用 .get(),不要假設每個欄位都存在。
六. 把 auth 邏輯集中到 auth.py #
接著建立 auth.py。
from __future__ import annotations
from dataclasses import dataclass
import streamlit as st
@dataclass(frozen=True)
class CurrentUser:
email: str
name: str
roles: set[str]
ROLE_BY_EMAIL = {
"admin@example.com": {"admin", "editor", "viewer"},
"editor@example.com": {"editor", "viewer"},
"viewer@example.com": {"viewer"},
}
def require_login() -> None:
if st.user.is_logged_in:
return
st.title("Team Dashboard")
st.write("請先登入。")
if st.button("Log in"):
st.login()
st.stop()
先集中登入牆。 再把 st.user 轉成 app 自己需要的資料:
def current_user() -> CurrentUser:
require_login()
email = st.user.get("email", "")
name = st.user.get("name", email or "teammate")
roles = ROLE_BY_EMAIL.get(email, set())
return CurrentUser(email=email, name=name, roles=roles)
接著寫角色檢查:
def require_role(role: str) -> CurrentUser:
user = current_user()
if role not in user.roles:
st.error("你沒有權限查看這個頁面。")
st.stop()
return user
最後加上側邊欄使用者選單:
def render_user_menu(user: CurrentUser) -> None:
st.sidebar.caption("Signed in as")
st.sidebar.write(user.name)
if user.roles:
st.sidebar.caption("Roles")
st.sidebar.write(", ".join(sorted(user.roles)))
if st.sidebar.button("Log out"):
st.logout()
目前 role 是硬寫在 ROLE_BY_EMAIL。 小團隊工具常常夠用。 規模變大後,可以換成資料庫、Google Group 同步結果,或公司內部權限服務。
頁面不需要知道 role 從哪裡來。 頁面只需要問:
user = require_role("admin")
這樣之後才好維護。
七. 用動態 navigation 控制頁面顯示 #
改寫 app.py。
import streamlit as st
from auth import current_user, render_user_menu
st.set_page_config(page_title="Team Dashboard", layout="wide")
user = current_user()
render_user_menu(user)
if not user.roles:
st.warning("你的帳號已登入,但尚未被授權使用這個工具。")
st.stop()
pages = [
st.Page("pages_app/home.py", title="Home", icon=":material/home:"),
]
if user.roles & {"viewer", "editor", "admin"}:
pages.append(
st.Page("pages_app/reports.py", title="Reports", icon=":material/analytics:")
)
if "admin" in user.roles:
pages.append(
st.Page("pages_app/admin.py", title="Admin", icon=":material/admin_panel_settings:")
)
selected_page = st.navigation(pages)
selected_page.run()
這樣使用者只會看到自己能用的頁面。 但要注意: 不要把「頁面沒顯示」當成唯一安全檢查。
UI 導覽只是第一層。 敏感頁面本身還是要檢查權限。 pages_app/admin.py:
import streamlit as st
from auth import require_role
user = require_role("admin")
st.title("Admin")
st.write(f"Hello, {user.name}.")
st.warning("這裡放管理功能。")
pages_app/reports.py:
import streamlit as st
from auth import require_role
user = require_role("viewer")
st.title("Reports")
st.write("這是團隊報表。")
st.caption(f"Loaded for {user.email}")
pages_app/home.py:
import streamlit as st
from auth import current_user
user = current_user()
st.title("Home")
st.write(f"歡迎,{user.name}。")
st.write("請從左側選擇工具。")
這樣有兩層保護:
- navigation 只顯示可用頁面。
- page entry 再檢查一次權限。
八. session_state 放登入後的 UI 狀態 #
登入後,session_state 還是很好用。 只是它應該管理 UI 狀態,而不是身份本身。 例如報表篩選條件:
import streamlit as st
from auth import require_role
user = require_role("viewer")
st.title("Reports")
if "report_filters" not in st.session_state:
st.session_state.report_filters = {
"team": "all",
"days": 7,
"show_internal": False,
}
filters = st.session_state.report_filters
team = st.selectbox(
"Team",
["all", "product", "data", "ops"],
index=["all", "product", "data", "ops"].index(filters["team"]),
)
days = st.slider("Days", 1, 90, filters["days"])
show_internal = False
if "admin" in user.roles:
show_internal = st.checkbox("Show internal metrics", filters["show_internal"])
if st.button("Apply"):
st.session_state.report_filters = {
"team": team,
"days": days,
"show_internal": show_internal,
}
st.rerun()
st.write("Current filters:", st.session_state.report_filters)
這裡的 session_state 很合理。 它記的是這個使用者目前選的篩選條件。 但真正查資料時,仍然要再檢查權限:
def load_report(*, include_internal: bool, roles: set[str]):
if include_internal and "admin" not in roles:
raise PermissionError("internal metrics require admin role")
return []
前端藏按鈕是一回事。 操作執行前檢查權限是另一回事。
九. 常見錯誤 #
第一個錯誤:把密碼寫在程式碼。
if password == "company-password-2026":
st.session_state.logged_in = True
不要。 它很容易被 commit、截圖、貼到 issue。 第二個錯誤:只用 email domain 當權限。
if st.user.email.endswith("@example.com"):
show_admin_page()
domain check 只能說「大概是公司帳號」。 它不代表這個人應該是 admin。 第三個錯誤:只藏頁面,不檢查操作。
if "admin" in user.roles:
st.button("Delete all data")
按鈕只對 admin 顯示很好。 但刪除 function 本身也要檢查:
def delete_all_data(user):
if "admin" not in user.roles:
raise PermissionError("admin only")
第四個錯誤:把身份 claim 印在正式頁面。
st.json(dict(st.user))
本機 debug 可以。 上線前請刪掉,或放在 admin-only debug 頁。 第五個錯誤:unknown user 預設開放。 建議預設沒有權限:
roles = ROLE_BY_EMAIL.get(email, set())
然後顯示清楚訊息:
if not user.roles:
st.warning("你的帳號尚未被授權,請聯絡管理員。")
st.stop()
預設關閉比較不刺激。
十. 什麼時候不要只靠 Streamlit? #
Streamlit auth 很適合:
- 小團隊內部 dashboard。
- 少數角色的資料工具。
- prototype 要給真實使用者試用。
- OIDC provider 已經由公司處理。 但如果你有這些需求,就該考慮完整後端:
- 多租戶 SaaS。
- 複雜資料列權限。
- 高風險寫入操作。
- 詳細 audit log。
- API token 或 service account。
- 使用者要代表自己呼叫第三方 API。 Streamlit 很適合 data app。 但不要把它硬凹成所有系統的安全核心。 先用 Streamlit 快速驗證流程。 如果工具變成正式業務系統,再把權限與敏感操作往後端移。 這樣速度和安全性比較不會互相吵架。
結語:登入不是一個按鈕,是一條邊界 #
這篇我們完成了 Streamlit auth 的基本架構:
- 用
st.login()導向 OIDC provider。 - 用
st.user讀取登入後身份。 - 用
st.logout()登出目前 app。 - 用
CurrentUser包裝 app 需要的資料。 - 用 role 控制
st.navigation()顯示頁面。 - 用
require_role()在敏感頁面再次檢查。 - 用
st.session_state管理 UI 狀態。 最重要的一句話: 身份、狀態、權限,請分開想。 分開以後,程式會比較好讀,debug 會比較不痛,之後改成資料庫或正式後端也比較順。 Streamlit 的魅力是快。 但快不是亂。 把登入邊界整理好,你的小工具就能從「自己用很方便」往「團隊用也放心」前進一大步。 拍拍君今天就先把門鎖裝好。 剩下的 dashboard,要漂亮也要守規矩。