Last updated: July 2026
A reader shipped a Python app to Fly.io last week and asked us how to add login to it. Here is the complete answer. The deploy part was the easy half: fly launch scanned their source, detected the framework, and generated everything needed to go live (Fly.io docs, 2026). Then they hit the part Fly.io deliberately doesn’t do: authenticating your users.
The fix is about 40 lines of Python. You point Authlib at a Keycloak realm, add three routes, and deploy. The catch is that Fly.io has two platform-specific traps, a redirect-URI mismatch and a multi-machine session problem, that break naive tutorials copied from localhost. We cover both, with the exact fixes. This is the Fly.io companion to our framework-agnostic guide to Python authentication for IAM.
Key Takeaways
- Fly.io has no built-in end-user auth; bring an OIDC provider like Keycloak.
- Fly Proxy terminates TLS at the edge, so Flask needs
ProxyFixor Keycloak rejects yourredirect_uri(Fly.io docs, 2026).- Fly.io has no native sticky sessions: use signed cookies or shared Redis.
What Does Fly.io Give You, and What’s Missing?
Fly.io handles deployment, TLS, and secrets, but ships zero end-user authentication. Its official Python guides cover FastAPI, Flask, and Django deploys (Fly.io Python docs, 2026), and every app gets a https://<app>.fly.dev URL with automatic HTTPS (Fly.io docs, 2026). A login page for your users? Not included.
That’s not a criticism, it’s a scope decision. Fly.io authenticates you to the platform through flyctl. Who logs into your app is your problem, and the standard answer in 2026 is OpenID Connect: your app redirects to an identity provider, the provider runs the login, and your app gets back a signed token.
Keycloak is the open-source workhorse for that role, and it works the same whether you run it yourself or use a managed instance. What you need from the platform side is exactly three things, and Fly.io provides all of them:
- HTTPS on a stable hostname, because OIDC redirect URIs must match exactly.
- Secret storage for the client secret, via
fly secrets set(Fly.io docs, 2026). - Environment variables in
fly.toml‘s[env]block for the non-secret config (Fly.io docs, 2026).
The 15-Minute Plan
Here’s the whole job, timed honestly. Five steps take you from “deployed but public” to “deployed with login.” The Keycloak side works identically for a Skycloak-managed instance or any Keycloak you run yourself, because everything flows through standard OIDC endpoints.
| Step | What you do | Where | Time |
|---|---|---|---|
| 1 | Create a confidential OIDC client | Keycloak admin console | 3 min |
| 2 | Add Authlib routes plus ProxyFix |
Your Flask app | 5 min |
| 3 | Set secrets and deploy | flyctl |
3 min |
| 4 | Pick a session strategy | fly.toml / Redis |
2 min |
| 5 | Test login, logout, and a second Machine | Browser | 2 min |
Steps 2 and 4 are where the Fly.io-specific traps live. If you’ve ever wondered why an OAuth flow that worked on localhost dies in production with “Invalid parameter: redirect_uri”, step 2 is your answer.
How Do You Configure the Keycloak Client?
Create a confidential client with an exact redirect URI. In the Keycloak admin console: Clients > Create client, type OpenID Connect, client ID something like fly-python-app. Turn Client authentication ON, which makes it a confidential client that authenticates with a secret, per the current OAuth Security Best Current Practice (RFC 9700, 2025).
Then set three fields:
- Valid redirect URIs:
https://<app>.fly.dev/auth, exactly. No wildcards. Keycloak compares this string character for character. - Valid post logout redirect URIs:
https://<app>.fly.dev/, so logout can send users home. - Client scopes: the defaults already include
openid,email, andprofile, which is all this flow requests.
Grab the client secret from the Credentials tab. Then confirm your realm is reachable by fetching its discovery document, which is where Authlib will pull every endpoint from:
curl https://<your-keycloak-host>/realms/<realm>/.well-known/openid-configuration
If that returns JSON with authorization_endpoint and token_endpoint, the Keycloak side is done. New to Keycloak entirely? Our complete Keycloak guide covers realms, clients, and scopes from zero.
Ship the Flask and Authlib App to Fly.io
One file carries the entire login flow. We use Authlib 1.7.2, current on PyPI as of May 2026, because its Flask client handles OIDC discovery, state checks, and PKCE for you. Note the ProxyFix line near the top: it’s load-bearing, and the next section explains why.
import os
from authlib.integrations.flask_client import OAuth
from flask import Flask, redirect, session, url_for
from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
app.secret_key = os.environ["SESSION_SECRET"]
# Fly Proxy terminates TLS at the edge and forwards plain HTTP.
# Trust its X-Forwarded-Proto / X-Forwarded-For headers so Flask
# generates https:// URLs. Without this, Keycloak rejects the redirect_uri.
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_for=1)
ISSUER = os.environ["KEYCLOAK_ISSUER"] # https://<keycloak-host>/realms/<realm>
oauth = OAuth(app)
oauth.register(
name="keycloak",
client_id=os.environ["KEYCLOAK_CLIENT_ID"],
client_secret=os.environ["KEYCLOAK_CLIENT_SECRET"],
server_metadata_url=f"{ISSUER}/.well-known/openid-configuration",
client_kwargs={
"scope": "openid email profile",
"code_challenge_method": "S256", # the only PKCE method Authlib supports
},
)
@app.route("/")
def index():
user = session.get("user")
if user:
return f"Logged in as {user['email']}. <a href='/logout'>Log out</a>"
return "<a href='/login'>Log in</a>"
@app.route("/login")
def login():
redirect_uri = url_for("auth", _external=True) # https://<app>.fly.dev/auth
return oauth.keycloak.authorize_redirect(redirect_uri)
@app.route("/auth")
def auth():
token = oauth.keycloak.authorize_access_token() # verifies state and ID token
session["user"] = token["userinfo"]
return redirect("/")
@app.route("/logout")
def logout():
session.clear()
post_logout = url_for("index", _external=True)
return redirect(
f"{ISSUER}/protocol/openid-connect/logout"
f"?client_id={os.environ['KEYCLOAK_CLIENT_ID']}"
f"&post_logout_redirect_uri={post_logout}"
)
Deploying is three commands. fly launch --no-deploy scans your source, detects Flask, and writes a fly.toml and Dockerfile you can review before shipping (Fly.io docs, 2026). Non-secret config goes in fly.toml:
app = "my-python-app"
primary_region = "iad"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
[env]
KEYCLOAK_ISSUER = "https://<your-keycloak-host>/realms/<realm>"
KEYCLOAK_CLIENT_ID = "fly-python-app"
The two sensitive values go in as secrets, which Fly injects as environment variables at runtime:
fly secrets set
KEYCLOAK_CLIENT_SECRET="paste-from-keycloak-credentials-tab"
SESSION_SECRET="$(openssl rand -hex 32)"
fly deploy
Two things worth knowing about fly secrets set. Setting a secret on a running app restarts every Machine immediately; use --stage and a later fly secrets deploy to defer that (Fly.io docs, 2026). And secret names can’t start with FLY_, which is reserved for the platform.
Open https://<app>.fly.dev, click log in, and Keycloak’s login page should appear. Unless you skipped the ProxyFix line, in which case read on.
Why Is Your Redirect URI http Instead of https?
Because your app never actually speaks HTTPS. TLS terminates at Fly Proxy, the edge layer, and your app receives plain HTTP on internal_port (Fly.io docs, 2026). Flask sees an http request, so url_for(_external=True) builds http://<app>.fly.dev/auth, and Keycloak rejects it: Invalid parameter: redirect_uri.
You registered https://<app>.fly.dev/auth, your app sent http://, and exact-match comparison did exactly its job. The confusing part is that your browser shows https the whole time, because force_https = true upgrades everything at the edge. The mismatch only exists in the query string your app generated.
Fly Proxy tells you the original scheme in every request. It sends X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Port, and Fly-Client-IP (Fly.io docs, 2026). There is no Fly-Forwarded-Proto, despite what some forum answers claim; the standard X-Forwarded-Proto header is the one to trust. Frameworks ignore forwarded headers by default, sensibly, since trusting them from arbitrary clients is a spoofing hole. You have to opt in:
- Flask:
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_for=1), as in the code above. Werkzeug then rewrites the WSGI scheme from the header. - FastAPI or anything on uvicorn: run
uvicorn main:app --proxy-headers --forwarded-allow-ips='*'in your Dockerfile’s CMD. The wildcard is fine here because on Fly.io only Fly Proxy can reach your app’s internal port.
In our experience this single line is the difference between “the tutorial worked” and an evening of staring at a Keycloak error page. It’s also why we put the fix in the code block instead of a footnote.
What Happens to Sessions Across Multiple Machines?
Fly.io has no native sticky sessions, so any state in one Machine’s memory is a coin flip. The proxy routes each request to the least-loaded Machine among the closest ones (Fly.io docs, 2026), and session affinity is a do-it-yourself pattern built on fly-replay headers (Fly.io blueprint, 2026).
Scale to two Machines with in-memory server sessions and users start getting logged out at random, but only sometimes, which makes it a miserable bug to reproduce. auto_stop_machines makes it worse: an idle Machine stops, its memory is gone, and so is every session it held (Fly.io docs, 2026).
You have two clean options:
- Signed cookies, the default in the code above. Flask’s built-in session is a cryptographically signed cookie, so the state travels with the browser and any Machine can validate it.
SESSION_SECRETis an app-wide Fly secret, so every Machine signs with the same key. Keep the payload to a few claims; cookies cap out around 4 KB. - A shared server-side store. If you need to hold access or refresh tokens server-side, which is where RFC 9700 says tokens belong rather than browser localStorage, put sessions in Redis. Upstash Redis is Fly.io’s first-party option:
fly redis createprovisions it in one command (Fly.io docs, 2026), and Flask-Session or your framework equivalent points at it.
Which one should you pick? Signed cookies until you’re storing tokens or session data that shouldn’t ride in a cookie, then Redis. Don’t reach for sticky sessions; on this platform that’s the workaround, not the feature.
What If Your Python App Is an API?
APIs skip the redirect dance and verify bearer tokens instead. Each request arrives with an access token in the Authorization header, and your job is signature, audience, and issuer checks on every one. PyJWT 2.13.0’s PyJWKClient does the heavy lifting: it reads the token’s kid header, fetches the matching key from Keycloak’s JWKS endpoint, and caches the key set (PyJWT docs, 2026).
import jwt
from jwt import PyJWKClient
ISSUER = "https://<your-keycloak-host>/realms/<realm>"
jwks = PyJWKClient(f"{ISSUER}/protocol/openid-connect/certs")
def verify(token: str) -> dict:
key = jwks.get_signing_key_from_jwt(token).key
return jwt.decode(
token,
key,
algorithms=["RS256"],
audience="fly-python-app",
issuer=ISSUER,
)
One library to avoid: python-jose. Versions up to and including 3.3.0 carry CVE-2024-33663, an algorithm-confusion vulnerability, and the advisory’s recommended fix is migrating to PyJWT. The full checklist, including Keycloak’s audience-mapper gotcha, is in our guide to verifying a Keycloak-issued access token on the backend. To sanity-check a live token against your realm’s keys in the browser, use our JWKS verifier.
Should You Self-Host Keycloak on Fly.io?
You can, and the honest answer is that you probably shouldn’t. Community guides from 2023 run the standard quay.io/keycloak/keycloak image against Fly Postgres, and it does boot. But Keycloak won’t run on Fly’s smallest Machines; the guides call for 512 MB minimum, and Keycloak’s own sizing model budgets roughly 1,250 MB of RAM per instance (Keycloak docs, 2026).
The operational fit is worse than the memory math. Fly Postgres is explicitly unmanaged, so backups, failover, and upgrades of your identity database are on you. auto_stop_machines, the feature that makes Fly.io cheap, kills Keycloak’s in-memory session caches every time the Machine idles out. And those 2023 guides predate several major Keycloak releases, so expect to debug the deltas yourself.
We’ve found the sensible split is: your app on Fly.io, your identity provider somewhere built to keep it alive. A managed instance (Skycloak hosting, for example) hands you the same standard OIDC endpoints this whole tutorial runs on, with none of the Postgres babysitting. Everything above works unchanged either way; that’s the point of building on a protocol instead of a platform SDK.
Frequently asked questions
Does Fly.io provide built-in user authentication for apps?
No. Fly.io authenticates you to the platform via flyctl, but offers no login system for your app’s end users. Its official Python docs cover deploying FastAPI, Flask, and Django (Fly.io docs, 2026); authentication is yours to add, typically via an OIDC provider like Keycloak.
How do I set secrets on Fly.io?
Run fly secrets set NAME=value; the value is injected as an environment variable. Setting a secret restarts every Machine immediately, so use --stage plus fly secrets deploy to defer (Fly.io docs, 2026). fly secrets list shows digests, never values, and names can’t start with FLY_.
Why is my OAuth redirect URI http instead of https on Fly.io?
TLS terminates at Fly Proxy, so your app receives plain HTTP and builds http:// URLs. Fly Proxy sends the original scheme in X-Forwarded-Proto (Fly.io docs, 2026). Honor it with Werkzeug’s ProxyFix in Flask, or --proxy-headers --forwarded-allow-ips='*' on uvicorn.
Does Fly.io support sticky sessions across machines?
Not natively. Fly Proxy routes each request to the least-loaded nearby Machine (Fly.io docs, 2026), and affinity is a DIY pattern using fly-replay headers. For login state, use signed cookie sessions or a shared store like Upstash Redis (fly redis create) instead.
Can you self-host Keycloak on Fly.io?
Yes, via the standard quay.io/keycloak/keycloak image plus Fly Postgres, following community guides from 2023. Expect friction: Keycloak’s sizing guidance budgets about 1,250 MB of RAM per instance (Keycloak docs, 2026), Fly Postgres is unmanaged, and auto-stop wipes in-memory sessions. A managed instance sidesteps all three.
Wrap-Up
The reader’s question has a short answer after all: one confidential Keycloak client, one Flask file with Authlib and ProxyFix, two fly secrets, and a deliberate session strategy. The OIDC flow itself is boring, which is exactly what you want from authentication. The Fly.io-specific work is knowing that TLS ends at the edge and that Machines share nothing.
Ship the code block above, test with a second Machine before you trust it, and keep tokens out of the browser per RFC 9700. And if running the identity provider is the part you’d rather skip, that’s a solved problem too.