Disclaimer: This works with Streamlit 1.44.1, I don't know if later Streamlit version changes the native authentication implementation or not, hence I cannot guarantee if this works with the latest Streamlit version.
Background
Streamlit ships with a native st.login() that handles authentication via OIDC.
It works but only to a point. The scopes are hardcoded to openid profile email, which means you can verify who the user is, but you can't do much beyond that.
No GCP API calls, no Google Sheets access, no Gmail, no nothing else. Just identity.
That wasn't enough for my Streamlit app. Users needed to actually do things — send emails, read and write Sheets, hit Drive. For that, I needed real Google API credentials scoped to those services, issued per user.
Two other things made st.login() a dead end for me:
- No refresh token — without offline access, the app can't do anything on the user's behalf once the initial token expires.
- No control over the token lifecycle — storage, expiry, and refresh are all beyond my control.
So I built my custom OAuth2 flow instead. It lets me request exactly the scopes I need, manage refresh tokens, and control session persistence through cookies with a configurable TTL.
How to
There are plenty of guides for the standard OAuth setup out there that explain wayy mo better than I do. Here's how I structured mine specifically for Streamlit.
The implementation lives in two modules:
auth_manager.py— handles the Google OAuth2 flow and user verificationtoken_manager.py— handles JWT creation, validation, and cookie storage
Because Streamlit re-runs the entire script on every user interaction, the auth check needs to sit at the top of your main entry point. In my case that's driver.py:
driver.py
This is basically just an outter UI layer and an UI button to login.
from auth.auth_manager import get_logged_in_user_credentials, get_user_info, auth_manager
# Some other lines of codes...
def handle_authentication():
user_info = get_user_info()
if not user_info:
return None, None
credentials = get_logged_in_user_credentials()
if st.button("Logout", icon=":material/logout:"):
auth_manager.logout()
st.rerun()
# More codes and the rest of the `driver.py`...auth_manager.py
This basically initialises the Google OAuth flow with specific scopes, checks if the user is already logged in (by checking cookies).
Pseudocode is included for length issue, links to the original source on GitHub attached down below.
MODULE SETUP
Read MODE from config (cloud or local)
Instantiate Authenticator with allowed domains, credentials path, redirect URI
CLASS Authenticator
__init__(allowed_domains, secret_path, redirect_uri, token_key, ...)
→ Stores config, initialises AuthTokenManager for cookie-backed sessions
_initialize_flow()
→ Builds a Google OAuth2 Flow object
→ In cloud mode: reads client_id and client_secret from st.secrets
→ In local mode: reads from a local credentials JSON file
→ Attaches the required GCP API scopes (Sheets, Drive, Gmail, user profile)
get_auth_url()
→ Runs _initialize_flow(), requests offline access + consent prompt
→ Returns the Google authorization URL to redirect the user to
login()
→ If not already connected: renders a "Login with Google" link button
check_auth()
→ If already connected: return early
→ Try to restore session from JWT cookie via AuthTokenManager
→ On success: populate session state and rerun
→ If an OAuth callback code is present in the URL:
→ Exchange code for tokens via _initialize_flow()
→ Fetch user profile (email, name) from Google
→ Validate email domain against allowed_domains
→ On pass: store JWT cookie, populate session state, rerun
→ On fail: show access denied toast
logout()
→ Clear all user fields from session state
→ Delete the JWT cookie via AuthTokenManager
EXPORTED FUNCTIONS
get_user_info()
→ Runs check_auth() if not connected
→ If still not connected: render login button and halt execution
→ Returns { email, name } from session state
get_logged_in_user_credentials()
→ Reads stored credentials dict from session state
→ Returns a Google Credentials object ready for API calls
→ If missing: triggers re-authentication and haltstoken_manager.py
This is the cookie and auth token manager. It basically checks if there is an existing auth token, set the auth token if not with custom TTL.
Again, pseudocode is included for length issue, links to the original source on GitHub attached down below.
CLASS AuthTokenManager
__init__(cookie_name, token_key, token_duration_days)
→ Stores cookie name, JWT signing key, and session TTL
→ Initialises the cookie manager (stx.CookieManager)
get_decoded_token()
→ Reads the named cookie from the browser
→ If absent: return None
→ Decode and verify the JWT; return the payload dict
→ If expired: show a toast, delete the cookie, return None
set_token(email, oauth_id, name, creds_dict)
→ Compute expiry timestamp (now + token_duration_days)
→ Encode a signed JWT containing user info + Google credentials
→ Write the JWT to the browser as a persistent cookie
delete_token()
→ Remove the named cookie from the browser
_encode_token(email, oauth_id, name, exp_date, creds_dict)
→ Sign a HS256 JWT payload:
{ email, oauth_id, name, exp, credentials }
→ Return the encoded token string
_decode_token()
→ Verify and decode the stored HS256 JWT
→ On ExpiredSignatureError: notify user and delete cookie