RANT -> Feeling very disillusioned building on Streamlit with lack of cookie support for cloud deployment

I have an incredible app that Streamlit has empowered me to the build that I couldn’t be more excited to share with my sales team. Cookies and OAuth work for me perfectly…locally (1.35.0)

But now that I have built out the multitenant capability to support a small team via my backend flask app, I can’t deploy because cookies won’t retain anything in the streamlit cloud or any cloud for that matter - docker or no docker

I tried the 3 most popular component libraries and even built my own with same result:

streamlit-cookies-controller

extra-streamlit-components (used in streamlit-authenticator)

streamlit-cookies-manager

So for me to share with the team, they have to log in constantly. Moreover, Google OAuth doesn’t work in streamlit community cloud (although OAuth works when I deploy elsewhere), so my users would have to MANUALLY log in each time if I were to use the community cloud. Ugh

So now that I’ve been doing this build for months on streamlit and reached this point in my journey, is it only to find out that when it comes to actually deploying that I’ve been wasting my time?

Sure, I can still share and force everyone to constantly log back in manually but that’s god awful. I don’t even want to use the app I created when I have manually login constantly

Have I been thinking about streamlit wrong this whole time? That it’s not for small team use but only for single user running locally??? If so, it means I’m back at the drawing board to build on react and never touch streamlit again.

This feels almost nightmarish to work so hard to build a great app and be this close to deployment but have to start the journey over again on a different framework simply over cookies and authentication.

It’s not sophisticated but I use the streamlit authenticator component with page navigation being the main script just rendering the selected file in the pages folder.

This works with auth cookies lasting for the 5 day window I set manually. No need for users to login repeatedly.

The one downside is that I have yet to build an actual user registration flow so I just make up passwords for them.

Other item of note is that I am not using flask and just run the file directly on Heroku.

Hey @brdemorin,

thanks for your feedback on auth! You are correct, today we don’t have a built-in auth solution directly in Streamlit, which mostly has historical reasons. We’re working on that at the moment (specifically adding SSO, see this GitHub issue), but unfortunately, the product manager working on this left a few weeks ago, so I don’t have a concrete timeline yet.

If you want to add auth to your app today, the best workarounds are:

  • Build a simple authentication yourself, e.g. by just querying for a password and comparing against that in Python code.
  • Use a custom component but I see you already tried a few and they didn’t work for you. Unfortunately, I never used these components myself, so can’t help too much, but maybe someone else here can.
  • Handle authentication outside of the app. Again, unfortunately I’m not an expert on this but I found a few examples here and here, and I’m sure there are a bunch more examples on the forum. We know about very sophisticated deployment systems that some companies built, so it’s definitely possible (but I know it’s not very convenient today :confused: ).
  • Deploy on a platform with built-in auth. E.g. Streamlit Community Cloud offers 1 private app per account that you can share with users via email, I think Huggingface Spaces allows you to have private apps as well, and we also have a paid offering within Snowflake.

Sorry that I can’t provide you with a better answer (yet) but please know that we’re on it! You’re also very welcome to leave comments and upvotes on the GitHub issue I linked above.

2 Likes

I appreciate the candid response @jrieke and sharing some context

I have authentication working in the app. It’s a bummer OAuth doesn’t work with the community cloud but that’s not a must. Manual credentials is fine. The problem is that cookies don’t work in a cloud deployment (although it works locally). Users have to manually login each time instead of staying logged in.

I was having a similar issue. I was able to solve (at least with a small test app) using streamlit_cookies_controller, Sqlite, in a docker container locally. You can see my test files here. User logged out after page refresh- Need persistent session - #2 by Josh2

I dockerized it by removing the setup_database.py after I already created my database. Then ran a pip freeze > requirements.txt . Then dockerize my 3 files- app.py, requirements.txt, and users.db.

# Use the official lightweight Python image
FROM python:3.9-slim

# Set the working directory in the container
WORKDIR /app

# Copy the requirements file to the working directory
COPY requirements.txt .

# Install the dependencies
RUN pip install --no-cache-dir -r requirements.txt

# Copy the rest of the application code to the working directory
COPY . .

# Expose the port Streamlit runs on
EXPOSE 8501

# Command to run the Streamlit app
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]

Build and then Run. It appears to keep state in Chrome from what I’ve tested while running in docker. Maybe it can work for you.

I’ve built an app with streamlit-keycloak + streamlit-cookies-manager back in 1.2x streamlit versions. The only problem is that streamlit-cookies-manager is not updated, so you need to patch 1 line from @st.cache to @st.cache_resource (or @st.cache_data, I don’t remember).

streamlit-cookies-manager supports prefixes & encryption in cookies, so you can safely use that in the public cloud. I.e. you need a unique prefix for your app and a strong encryption token.

My setup basically:

  • if no session id in cookies = not logged in = send to login page via streamlit-keyclock
  • on succesful login (with optional verification via secure back-end) - generate own id and store it both in sqlite on streamlit side and in user’s cookies. This id associates with keycloak token (I don’t like to expose it). cookies are encrypted with strong key (random-generated on first app run and saved to persist between reruns; similar mechanics are used for the first admin login with secure token - to set up the instance securely).
  • on page rerun - cookies id is saved to session state and verified for expiration in-memory; if expired - recheck with keycloak and repeat the first step if required (unless can refresh the token).

P.S.: F5 is a bad way to refresh Streamlit SPA - you kill the session state. Usually it’s best to use streamlit menu in the top-right corner of the page - rerun there, or change the process workflow (for example, if you need to regularly update data on page - either use an async cycle or something like streamlit-timer for JS-based timer callbacks, or @st.fragment with timer for example)

P.P.S.:

This feels almost nightmarish to work so hard to build a great app and be this close to deployment but have to start the journey over again on a different framework simply over cookies and authentication.

I understand the frustration, but this could really work with Streamlit too. I really love Streamlit, but dev team is a bit too slow on implementing heavily wanted features, like auth or at least - cookies. Leave alone HTML customization. I also had to switch off streamlit for a large project, but mostly due to lack of dialog and tree navigation at that time.

1 Like

@CHerSun I really appreciate the detailed response. I tried the streamlit-cookies-manager component and while it worked locally, it didn’t work on streamlit community cloud or other clouds - docker or no docker.

I also tried @james2 suggestion of using the streamlit-authenticator component. It uses extra-streamlit-components which failed for me but I thought what the hell. I jumped out of my seat with joy when my first attempts showed cookies working in the streamlit community cloud so I went gung ho all day building on it but then cookies stopped working. Then after going back to the earlier versions that did work…they were no longer working. Fine locally but again, not in streamlit community cloud nor docker on GCP

So now I’m back at the drawing board and mired in the mud wondering if I need to go build on react instead JUST OVER COOKIES

Our setup is in custom cloud (Kubernetes) and using oauth but not the various components out there but a custom implementation proxying through three layers of authentication (custom SSO layer → git provider → company SSO)

As you can imagine this will lead to many (up to 6) redirects so we added syncing with localstorage AND session state of the token to improve UX.

So when user enters page first redirected to oauth provider. In the end we receive an auth-token we can add to localstorage as well as session state. Session state will share this token with other pages, while localstorage sync shares the tokens with other sessions within the same browser (not incognito ofc).

Next time the user enters , it takes token from localstorage and hydrates the app with the user information without the need of going through the entire auth flow.

Tbh, I didn’t run such setup in any cloud, but the app I mentioned was working in internal company network, containerized, behind NGINX reverse proxy (TLS termination mainly). No issues with cookies there. We needed to add some headers to nginx config, but this was already a few years back - I just don’t remember which specifically.

I have used streamlit-keycloak and it does a decent job. You should give it a try!

You can onboard Enterprise uses into Keycloak easily (if you know LDAP/AD details) and then Keycloak takes care of rest. Its almost transparent to the end-user. They will authenticate using their AD.

I also implement page-level authorization manually so that not every authenticated user can access every page.

Simpul Humbul Gentul

Since I didn’t see it mentioned in this thread yet, I’ll mention here for posterity that Streamlit 1.37.0 introduced native support for reading cookies and headers. This was intermediate work as part of the larger project to implement the native authentication that @jrieke mentioned.

2 Likes

@PZ1 Well done. I tried taking the localStorage approach as well after I was blocked on cookies. However, my js attempt didn’t work so I abandoned. The fact that you got it working is incredibly promising. Did you build your own custom component for this? A 3rd party component? Any guidance would be GREATLY appreciated followed by a waterfall of starbucks gift cards :wink:

For everyone else, I have been chatting with Mohammed behind streamlit-authenticator and extra-streamlit-components. He said components stopped working to set/get cookies in cloud deployments in the more recent streamlit versions and that he himself is waiting on streamlit for st.cookies to solve this

A side note is that I tried deploying with different cloud platforms (e.g. Heroku, Render, etc.) with suspicion that if it works locally then maybe in SOME cloud out there but no.

@Sarnath_K thx for the tip. I’ll give it a try as it might be the only auth component I haven’t tried. What version of streamlit are you on?

I’ll post some snippets tomorrow, it’s pretty simple. It relies on streamlit_js for client side js execution.

Are you interested in the rest of the auth flow as well?

@PZ1 that would be amazing. I’ve been spinning my wheels for days trying to solve this.

If it’s easiest just to share the localStorage js piece now, that would be enough but would love to see all of it. And mind letting me know what version of ST you are using? I’m 1.35.0

streamlit==1.32.1
streamlit-keycloak

This is my requirements.txt and so that’s the latest version of streamlit-keycloak.

It’s not that only this version will work… but it is perhaps one of the versions that is working. Try your luck! Good luck!

The LocalStorage code is as shown below, it uses this external component for JS execution:

(Streamlit JS)[GitHub - toolittlecakes/streamlit_js: Run JS code within Streamlit with a way to check if done]

import streamlit as st
from streamlit_js import st_js
import logging

class LS:
    BASE = '_LS_'
    counter = 1

    
    @classmethod
    def set(cls, key, value):
        a = cls.load_all()
        logging.info(f"JSON.stringify('{value}')")
        
        if type(value) == dict:
            import json
            str_value = json.dumps(value)
            st_js(code = f"""
                console.log('{key}')
                
                localStorage.setItem('{key}','{str_value}')
                
                """, key  =  '_set_' + str(cls.counter))
        else:
            st_js(code = f"""
                console.log('{key}')
                console.log(JSON.stringify('{value}'))
                localStorage.setItem('{key}', JSON.stringify('{value}'))
                
                """, key  =  '_set_' + str(cls.counter))
        a[key] = value
        cls.counter += 1
        

    @classmethod
    def get(cls, key, default = None): 
        a = cls.load_all()
        return a.get(key, default)
    

    @classmethod
    def load_all(cls):
        if cls.BASE + 'all' not in st.session_state:        
            cls._load_all()
        return st.session_state.get(cls.BASE + 'all', {})

    @classmethod
    def keys(cls):
        a = cls.load_all()
        return list(a.keys())

    @classmethod
    def delete(cls, key ):
        a = cls.load_all()
        # del st.session_state[cls.BASE + 'all'][key]
        st.session_state[cls.BASE + 'all'] = {k: v for k, v in a.items() if k != key}
        st_js(code = f"""localStorage.removeItem('{key}')""", key  =  '_del_' + str(cls.counter))
        cls.counter += 1
        

    @classmethod
    def _load_all(cls):
        code = """
        // Create an empty object to store all key-value pairs
        let localStorageItems = {};

        // Iterate over all keys in localStorage
        for (let i = 0; i < localStorage.length; i++) {
            let key = localStorage.key(i);
            let value = JSON.parse(localStorage.getItem(key));
            localStorageItems[key] = value;
        }

        // The `localStorageItems` object now contains all key-value pairs
        return localStorageItems;
        """
        logging.info("Loading")
        items = st_js(code =code, key  =  '_load_all')
        try:
            st.session_state[cls.BASE + 'all'] = items[0]
            return items[0]
        except:
            return {}
        

The OAUTH flow is below, I ripped out some company specific code and tidied up some parts so it won’t work out of the box, it’s more to give an idea of how one could implement it. It hasn’t been screened for security vulnerabilities either.

You can break the logic down into three steps:

  1. If oauth in LocalStorage or session state ? → You are logged in → Proceed to rest of app
  2. If access_token is in query parameters ? → Fetch user info, add to LocalStorage and session state → Proceed to rest of app
  3. If neither of above is true → Redirect to the OAUTH provider

As seen below we have a service in between the OAUTH Provider and the applcation, the main reason is to provide pattern-matching on redirect URL for subdomains (I think most implementations do not support wildcards), but it could also be used for caching, TTLs, renewals, hydration of user info etc. I never used Community Cloud so don’t really now how it would work with that.

I hope this is useful and let us know what solution you end up implementing!

from .localStorage import LS

# Redirect script
def nav_to(url):
    nav_script = """
        <meta http-equiv="refresh" content="0; url='%s'">
    """ % (url)
    st.write(nav_script, unsafe_allow_html=True)

def get_user_info(access_token):
    # Should parse user info and return dict

def auth():
    ok = False
    import os
    # Check if auth is enabled
    qps = st.query_params.to_dict()
    if os.getenv('OAUTH', 'false').upper() == 'TRUE':

        from streamlit_jsimport st_js
        # if 'oauth' in local_storage use it 
        if  'oauth' in LS.keys():
            logging.info('Loading oauth from local storage ')
            toast = False
            if 'oauth' not in st.session_state:
                toast = True
            st.session_state['oauth'] = LS.get('oauth')
            if toast:
                user = st.session_state['oauth']
                st.toast(f"Logged in as {user['name']} - Cached", icon='✅')


        if 'oauth' in st.session_state:
            ok = True
        # If access token is in query parameters
        elif 'access_token' in qps

            user = get_user_info(qps['access_token'])
            if type(user)==dict:
                
                qps.pop('access_token', {})
                st.query_params.from_dict(qps)

                st.session_state['oauth'] = user
                LS.set('oauth', user)
                ok = True
                

                st.toast(f"Logged in as {user['name']}", icon='✅')
                
            else:
                st.error('Something went wrong when authenticating user.')
      # Handle initial redirect to OAUTH service  
      else:
            # Get the current url 
            url = st_js("await fetch('').then(r => window.parent.location.href)",key = 'url')
            if type(url)!=str:
                url = st.session_state.get('url', '')

            if url:

     

                if 'access_token' in url:
                    access_token = url.split('access_token=')[1]
                    st.session_state['access_token'] = access_token
                    st.write(access_token)
                    st.rerun()
                else:
                   
                    auth_url  = f"https://custom-sso-proxy.com/sso/{url}"
                    
                    nav_to(auth_url)
                    st.stop()
    else:
        ok = True
    return ok

@Sarnath_K I gave it a go but ran into the same bug reported here. My comment is at the bottom of the thread: BUG :authenticated has not changed · Issue #32 · bleumink/streamlit-keycloak · GitHub

Any idea what keycloak version you are running? I’m using the latest from RedHat → quay.io/keycloak/keycloak:25.0.4

@PZ1 Wow! I’m getting the coffee brewing now to crank this out. If this works, there is a gift basket thank you coming your way.

Hi, I implemented a custom discord oauth flow (works similarly with any sso) using mostly only python and js only to create a custom login button.

This is working in a cloud deployed app (streamlig 1.37) using GCP and it does not require additional packages. It should be compatible with older Streamlit versions as well by removing the newest decorators.

The logic is as follows:

  • a login/logout button in rendered on depending on the absence/presence of user data
  • if the user clicks on the login button, the auth flow is started and the user is redirected to the discord login page
  • once the login completes, the user is reditected to the oauth2-callback page of the streamlit app where the auth code is exchanged for an access token
  • when the operation is completed, the user is redirected to the homepage
  • when the user is logged in, user data is filled and the login button is transformed into a div with the user image and logout button. You may want to improve the html code for this, as a base i would suggest to add the following into the index.html head:
    .profileImage {
      width: 50px;
      height: 50px;
      border-radius: 50%;
    }

Of course you can customize the redirect link to redirect the user wherever you want, maybe based on where he/she was before loggin in. For more information check the auth flow guide of your SSO provider.

The code is structured as follows:
app.py
pages/oauth2-callback.py
src/utils/frontend.py
src/utils/discord.py

Helper functions for frontend (frontend.py)

def create_link_button(key: str, url: str, label: str | None = None, icon: str | None = None) -> None:
    st.html(
        f'''
        <style>
            .{key} {{
                display: inline-flex;
                align-items: center;
                justify-content: center;
                font-weight: 400;
                padding: 0.25rem 0.75rem;
                border-radius: 0.5rem;
                min-height: 38.4px;
                margin: 0px;
                line-height: 1.6;
                text-decoration: none;
                width: 100%;
                user-select: none;
                background-color: rgb(43, 44, 54);
                color: rgb(250, 250, 250) !important;
                border: 1px solid rgba(250, 250, 250, 0.2);
            }}

            .{key}:hover {{
                border-color: rgb(255, 75, 75);
                color: rgb(255, 75, 75);
            }}

            .{key} p, .{key} img {{
                margin: 0;
            }}
        </style>

        <div class="row-widget stLinkButton" data-testid="stLinkButton">
          <a class="{key}" data-testid="baseLinkButton-secondary" 
            kind="secondary" href="{url}">
            <div data-testid="stMarkdownContainer">
                {f'<img src="{icon}" width="50px" height="50px">' if icon else ''}
                {f'<p>{label}</p>' if label else ''}
            </div>
        </a>
        </div>
      '''
    )


def logout() -> None:
    revoke_token(st.session_state.access_token)
    st.session_state.pop('logged_user_data')
    st.session_state.pop('access_token')
    st.session_state.logout_requested = True


def show_user_icon() -> None:
    st.html(f'''
        <div>
        <img class="profileImage" src="{st.session_state.logged_user_data.get('avatar_url')}"/>
        </div>
    ''')

@st.fragment
def show_user_data() -> None:
    if 'logged_user_data' not in st.session_state:
        st.write('Login to restart your past chats at any time')
        create_link_button(
            "login_btn",
            get_login_url(),
            icon="https://cdn3.emoji.gg/emojis/7561-discord-clyde.png"
        )
        if st.session_state.get('logout_requested'):
            st.toast('Logged out')
            st.session_state.logout_requested = False
            if st.session_state.get('start_chat'):
                reset_app()
            st.switch_page(PagesEnum.HOME_PAGE)
    else:
        st.write(
            f"Logged in as {st.session_state.logged_user_data.get('username')}")
        show_user_icon()
        if st.session_state.get('login_requested'):
            st.toast(
                f"Welcome {st.session_state.logged_user_data.get('username')}!")
            st.session_state.login_requested = False
       
        st.button(
            key="logout_btn",
            label="Logout",
            on_click=logout,
            disabled=st.session_state.logout_requested
        )

SSO functions (discord.py)

from os import getenv
from requests import post, get

if not getenv('ENVIRONMENT'):
    from dotenv import load_dotenv
    load_dotenv(override=True)

DISCORD_API_ENDPOINT = 'https://discord.com/api'
DISCORD_CDN_URL = 'https://cdn.discordapp.com'
REDIRECT_URI = f"{getenv('BASE_URI')}/oauth2-callback"
DISCORD_CLIENT_ID = getenv('DISCORD_CLIENT_ID')
DISCORD_CLIENT_SECRET = getenv('DISCORD_CLIENT_SECRET')

LOGIN_URL = f'https://discord.com/oauth2/authorize?client_id={DISCORD_CLIENT_ID}&response_type=code&redirect_uri={REDIRECT_URI}&scope=identify&prompt=none'
LOGOUT_URL = f'{DISCORD_API_ENDPOINT}/oauth2/token/revoke'


def get_login_url() -> str:
    return LOGIN_URL


def exchange_code(code) -> str:
    data = {
        'grant_type': 'authorization_code',
        'code': code,
        'redirect_uri': REDIRECT_URI
    }
    headers = {
        'Content-Type': 'application/x-www-form-urlencoded'
    }
    r = post(f'{DISCORD_API_ENDPOINT}/oauth2/token', data=data,
             params={'state': '1234'},
             headers=headers, auth=(DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET))
    access_token = r.json()['access_token']
    return access_token


def get_user_data(token) -> dict[str, str]:
    headers = {
        'Authorization': f'Bearer {token}'
    }
    r = get(f'{DISCORD_API_ENDPOINT}/users/@me', headers=headers)
    r.raise_for_status()
    user_data = r.json()

    if user_data.get('avatar'):
        avatar_url = f"{DISCORD_CDN_URL}/avatars/{user_data.get('id')}/{user_data.get('avatar')}.png"
    else:
        avatar_url = f"{DISCORD_CDN_URL}/embed/avatars/{(int(user_data.get('id')) >> 22) % 6}.png"

    user_data['avatar_url'] = avatar_url
    user_data['provider'] = 'discord'
    user_data.pop('avatar')
    return user_data


def revoke_token(access_token) -> None:
    data = {
        'token': access_token,
        'token_type_hint': 'access_token'
    }
    headers = {
        'Content-Type': 'application/x-www-form-urlencoded'
    }
    r = post(
        LOGOUT_URL,
        headers=headers,
        data=data,
        auth=(DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET)
    )
    r.raise_for_status()

Oauth callback page to manage the auth flow redirect (oauth2-callback.py)

import streamlit as st
from src.utils.discord import exchange_code, get_user_data
from src.utils.frontend import reset_app
from src.utils.pages import HOME_PAGE

reset_app()
st.session_state.access_token = exchange_code(st.query_params['code'])
st.session_state.logged_user_data = get_user_data(
    st.session_state.access_token)
st.session_state.login_requested = True
st.session_state.has_new_chat = True
st.switch_page(HOME_PAGE)
1 Like

@PZ1 Success! Your authflow is fantastic. Your LS class is the ONLY thing I have tried that has enabled me to persist a token. You have saved my project. Gift basket, love, appreciation and admiration coming your way. Adapted “_load_all” to work with the different cloud platforms I deployed to.

@classmethod
def _load_all(cls):
    code = """
    let localStorageItems = {};
    for (let i = 0; i < localStorage.length; i++) {
        let key = localStorage.key(i);
        let value = localStorage.getItem(key);

        // Try parsing the value; if it fails, use the raw string
        try {
            value = JSON.parse(value);
        } catch (e) {
            // Keep value as-is if parsing fails
        }

        localStorageItems[key] = value;
    }
    return localStorageItems;
    """
    logging.info("Loading")
    items = st_js(code=code, key='_load_all')
    try:
        if isinstance(items, list):
            if len(items) > 0 and isinstance(items[0], dict):
                st.session_state[cls.BASE + 'all'] = items[0]
                return items[0]
            else:
                logging.warning(f"LS class -> List format unexpected or empty: {items}")
                return {}
        elif isinstance(items, dict):
            st.session_state[cls.BASE + 'all'] = items
            return items
        else:
            logging.warning(f"LS class -> Unexpected format of items: {type(items)} - {items}")
            return {}
    except Exception as e:
        logging.error(f"Error loading items: {e}")
        return {}

Is there a support group for streamlit Auth survivors? I thought I was losing my mind on how deeply I was going as someone who is NOT an auth expert and thought I would never have to be.

Now off to the fun stuff spending my weekend building an entire auth workflow from scratch

@andreasntr impressive and thanks for sharing. I haven’t anyone do that yet. What are you doing to persist the refresh token so users don’t have to constantly log back in. Also, I couldn’t get the OAuth params back from keycloak populated in streamlit. Keycloak did 100% of its job but no good if streamlit isn’t getting params it can process. How are you getting that to work?

1 Like