Back to posts

How to Add Login and a Paywall to a Streamlit App

April 02, 2026

By Rian Dolphin

My wife is in her final year of medicine. She got a spreadsheet with 866 job posts for next year, each with four rotations. Scrolling through it was miserable. So I built a Streamlit dashboard to filter and rank them. It worked well enough that we thought it could be helpful for other students. However, if I was going to buy a domain and host it etc. I thougth I might as well try to implement auth and payments. It was my first time implementing payments so a great learning opportunity on that front.

This turned out to be more fiddly than I expected. Not hard exactly, but full of small discoveries that aren't obvious from the docs. This is my attempt to write down everything I learned so I can do it again without the fumbling.

The starting point

I had a working Streamlit app. Single app.py file, data in a parquet file, filters in the sidebar, results in the main area. Nothing fancy. The goal was to add three things: Google login, Stripe payments, and a preview mode for users who hadn't paid yet.

Setting up Google OAuth

Streamlit has built-in auth since version 1.42. You call st.login() and st.logout() and check st.user.is_logged_in. But it doesn't just work out of the box. You need to configure an OIDC provider.

I went with Google since most people have a Google account. Here's what you do:

  1. Go to https://console.cloud.google.com
  2. Create a project (or use an existing one)
  3. Go to APIs & Services > OAuth consent screen and set it up as External
  4. Go to Credentials > Create Credentials > OAuth 2.0 Client ID
  5. Set type to Web application
  6. Add http://localhost:8501/oauth2callback as an Authorized redirect URI
  7. Copy the Client ID and Client Secret

Then create .streamlit/secrets.toml:

[auth]
redirect_uri = "http://localhost:8501/oauth2callback"
cookie_secret = "any-random-string"
client_id = "your-client-id.apps.googleusercontent.com"
client_secret = "GOCSPX-your-secret"
server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"

You also need to install authlib:

pip install authlib

One thing that tripped me up: st.user.is_logged_in doesn't exist at all unless the [auth] section is configured. It's not that it returns False. The attribute literally isn't there, and you get an AttributeError. So if you're deploying somewhere and forget the secrets file, you'll get a confusing error that looks like a version problem but isn't.

The auth flow in code

if not st.user.is_logged_in:
    st.title("My App")
    if st.button("Log in to get started"):
        st.login()
    st.stop()

# Everything below here requires login
if st.sidebar.button("Log out"):
    st.logout()

st.stop() is the key. It halts execution so the rest of the app never renders for unauthenticated users.

Adding Stripe payments

My first instinct was to use st-paywall, a package specifically for this. I installed it and it looked promising. One function call, add_auth(), and you're done.

Then I discovered it only supports subscriptions. I wanted a one-time payment. Looking at the source code, the is_active_subscriber function explicitly calls stripe.Subscription.list(). If you make a one-time payment, it will never find it.

So I ditched st-paywall and wrote my own check using the stripe package directly. It's not much code. But there was a second surprise waiting.

The customer problem

My first attempt looked like this:

def has_paid(email):
    stripe.api_key = STRIPE_API_KEY
    customers = stripe.Customer.list(email=email)
    if not customers.data:
        return False
    customer = customers.data[0]
    charges = stripe.Charge.list(customer=customer["id"], limit=1)
    return any(c["paid"] and c["status"] == "succeeded" for c in charges.data)

I made a test payment. It went through on Stripe. But has_paid returned False.

The problem: Stripe payment links don't create a Customer object. The payment goes through, but there's no customer to look up. The email is buried in the checkout session's customer_details field, not attached to a customer record.

Here's what actually works:

def has_paid(email):
    stripe.api_key = STRIPE_API_KEY
    sessions = stripe.checkout.Session.list(status="complete", limit=100)
    return any(
        s.payment_status == "paid"
        and s.customer_details
        and s.customer_details.email == email
        for s in sessions.auto_paging_iter()
    )

This iterates through completed checkout sessions and checks if the email matches. auto_paging_iter() handles pagination so you don't miss payments beyond the first page.

Setting up the Stripe side

  1. Create a Stripe account at https://dashboard.stripe.com
  2. Create a product in Product catalogue > Add product. Set pricing to One time.
  3. Create a payment link in Payment links > New. Select your product.
  4. Copy the payment link URL and your secret API key from Developers > API keys

When testing, use Stripe's test mode. The test card number is 4242 4242 4242 4242, any future expiry, any CVC. You'll need separate products and payment links for test and live mode. They have completely separate catalogues.

In your app, store the keys as environment variables:

from dotenv import load_dotenv
load_dotenv()

STRIPE_API_KEY = os.environ.get("STRIPE_API_KEY", "")
STRIPE_PAYMENT_LINK = os.environ.get("STRIPE_PAYMENT_LINK", "")

The payment flow

When a logged-in user hasn't paid, show them a link to Stripe with their email prefilled:

if not has_paid(st.user.email):
    encoded = urllib.parse.quote(st.user.email)
    payment_url = f"{STRIPE_PAYMENT_LINK}?prefilled_email={encoded}"
    st.link_button("Unlock full access", payment_url)
    st.stop()

The ?prefilled_email= parameter saves the user from typing their email again on the Stripe checkout page. More importantly, it means the email on the payment matches the email from Google login, which is how we verify they've paid.

The redirect problem

After paying, Stripe shows a confirmation page but doesn't redirect back to your app. The user has to manually go back and refresh. You can set a custom redirect URL in the payment link settings under the After payment tab, but only when creating the link. You can't edit it after.

My workaround was adding an "I've just paid! Refresh" button that calls st.rerun(). Not elegant, but it works. You can also add a custom message on the Stripe confirmation page telling users to go back to the site.

Preview mode

I wanted non-paying users to see a working preview with a fraction of the data, not just a blank paywall. This turned out to be simple. Sample 5% of posts deterministically and show them with a banner explaining they're seeing a subset:

is_logged_in = st.user.is_logged_in
user_has_paid = is_logged_in and has_paid(st.user.email)

if user_has_paid:
    df = df_full
else:
    sample_posts = (
        df_full["Post Reference"]
        .unique()
        .sort()
        .gather_every(20)  # every 20th post = 5%
    )
    df = df_full.filter(pl.col("Post Reference").is_in(sample_posts))

The rest of the app runs the same code regardless. It just has less data to work with.

Deploying to Railway

I deployed from a GitHub repo to Railway. A few things to know:

The secrets.toml needs to be in the repo. I initially had it in .gitignore, which meant Railway never got the auth config. My repo is private so I just committed it. If it's public, you'll need another approach.

Update the redirect URI. The redirect_uri in secrets.toml needs to match your production domain:

redirect_uri = "https://yourdomain.com/oauth2callback"

And add the same URI to your Google OAuth client's Authorized redirect URIs in Google Cloud Console. If you forget this step, Google will reject the login attempt.

DNS takes time. If you just bought a domain, give it a few minutes to a few hours to propagate. Check progress at https://dnschecker.org. Select CNAME from the dropdown (if using Railway + Cloudflare, anyway).

The full auth section

Putting it all together, the auth section of the app looks like this:

import os
import urllib.parse
import stripe
import streamlit as st
from dotenv import load_dotenv

load_dotenv()

STRIPE_API_KEY = os.environ.get("STRIPE_API_KEY", "")
STRIPE_PAYMENT_LINK = os.environ.get("STRIPE_PAYMENT_LINK", "")


def has_paid(email: str) -> bool:
    stripe.api_key = STRIPE_API_KEY
    sessions = stripe.checkout.Session.list(status="complete", limit=100)
    return any(
        s.payment_status == "paid"
        and s.customer_details
        and s.customer_details.email == email
        for s in sessions.auto_paging_iter()
    )


# Landing page for visitors
if not st.user.is_logged_in:
    st.title("My App")
    st.write("Description of what this does.")
    if st.button("Log in to get started"):
        st.login()
    st.stop()

# Payment gate
if not has_paid(st.user.email):
    encoded = urllib.parse.quote(st.user.email)
    payment_url = f"{STRIPE_PAYMENT_LINK}?prefilled_email={encoded}"
    st.warning("Purchase access to use this app.")
    st.link_button("Unlock full access", payment_url, type="primary")
    if st.button("I've just paid! Refresh"):
        st.rerun()
    st.stop()

# Logged out button
if st.sidebar.button("Log out"):
    st.logout()

# ... rest of your app

What I'd do differently

The has_paid function iterates through all checkout sessions every time. For a small number of users that's fine. If this ever scaled, I'd cache the result in a database or at least in st.session_state so you're not hitting the Stripe API on every page interaction.

I'd also set up the Stripe payment link redirect URL from the start rather than needing the refresh button workaround. It's a small thing but it makes the UX noticeably smoother.

I ended up migrating the whole thing to FastHTML so that I could add a shortlist feature with multiple pages and drag and drop to reorder. So perhaps the bigger lesson here is that if you want to build something that might expand beyond a simple dashboard, don't use Streamlit at all!