If you've ever built auth, you know how sessions start out: Your user logs in => slap a token in a DB table, set a cookie, move on with your life.
If you are building a B2B application (or just any application with aspirations of selling to larger customers), that simple flow quickly gets complicated.
An important customer swears they got randomly logged out and you need to reconstruct the timeline of their session. An enterprise prospect asks for "12-hour sessions and only from office IPs." A security review comes back and says you need to implement session rotation to limit the damage from session hijacking attempts. Individually these asks are small; together they turn your clean session code into an ugly mess of conditionals.
PropelAuth BYO (Bring Your Own) gives you a sidecar you run next to your app that handles the gnarly parts while you keep full control over cookies, routes, and UX. You stay in your stack; BYO handles the session brain - validation, rotation, device checks, audit logs, and per-customer policies - behind a tiny client. It's self-hosted and only needs Postgres.
Below, we'll look at how to hook up PropelAuth BYO Sessions into an existing Flask app in a few minutes.
Why sessions get complex over time
- Debuggability - When something is weird, you need answers: which IP, which device, why did validation fail? BYO emits structured JSON logs and includes a dashboard for deep dives into "active vs expired," detailed expiration reasons, IP, and more.
- Defense in depth - Add rotation so stolen tokens die fast. Add new-device notices so users can spot suspicious logins. Add device verification so a stolen cookie is useless without the device's keys. All of that layers cleanly on the same session flow.
- User-Agent shifts - Chrome minor version changes? Super reasonable. Mac to Windows mid-session? Probably not. If you pass the User-Agent into create/validate, BYO will flag suspicious changes, invalidate if needed, and leave a breadcrumb in audit logs so you can explain "why did I get logged out?" to a customer.
- IP Address shifts - A change in IP is more ambiguous. It could be a sign the user is on mobile, it could be a sign a user's moving around in an office, or it could be a sign a user's device was compromised. With BYO, you have tools at your disposal to fit your application. You can issue invisible device challenges on IP change or automatically invalidate for very sensitive sessions.
Wiring BYO into a Flask app
Prerequisites: An existing Flask app with login
We're going to assume that you have a Flask application already with a login route that verifies user credentials. Here's an example showing the relevant parts:
app = Flask(__name__)
# This route verifies email/password and returns a session token
@app.route("/login", methods=["POST"])
async def login():
email = request.json.get("email")
password = request.json.get("password")
user = authenticate_user(email, password)
if not user:
return jsonify({"error": "Invalid credentials"}), 401
# Generate an opaque session token and store it
session_token = secrets.token_urlsafe(32)
store_session_token(session_token, user.user_id)
return jsonify({"access_token": session_token, "token_type": "bearer"})
# This decorator validates session tokens
def login_required(f):
@wraps(f)
async def decorated_function(*args, **kwargs):
token = request.headers.get("Authorization", "").replace("Bearer ", "")
if not token:
return jsonify({"error": "Unauthorized"}), 401
# Look up the session token in our storage
user = get_user_id_from_token(token)
if not user:
return jsonify({"error": "Unauthorized"}), 401
g.current_user = user
return await f(*args, **kwargs)
return decorated_function
This might look different in your app, but the key points are:
- You have a login route that verifies credentials and returns a session token.
- You have a decorator that validates session tokens on protected routes.
- You can use that decorator on routes you want to protect.
Now let's look at how we can swap in PropelAuth BYO for session management.
1. Run the BYO sidecar
Following the instructions in the docs, we'll first clone a repo with both a docker-compose.yml as well as some example config files.
git clone git@github.com:PropelAuth/byo-config-template.git
cd byo-config-template
cp .env.example .env
Then we'll edit our .env file to add both our BYO_LICENSE_KEY and INITIAL_OWNER_USERNAME:
BYO_LICENSE_KEY="pa_..."
INITIAL_OWNER_USERNAME="root"
You can create your license by following the instructions here. Then, we can run the sidecar:
docker compose -f compose.yaml up -d
Navigate to http://localhost:2884 and you'll see the BYO dashboard. You can log in with the username you set in .env and the password thispasswordistemporary (you'll be prompted to change it).

2. Install and configure the BYO client
Now that we have the sidecar running, we can install the BYO client in our Flask app:
pip install propelauth-byo
# app/auth.py
from propelauth_byo import create_client
auth_client = create_client(
url="http://localhost:2884", # your sidecar URL
integration_key="api_..." # from BYO dashboard
)
You can find your integration key in the BYO dashboard under Settings > Integration Keys.
3. Create a session at login
Now it's time to update our login route to create a session with BYO:
@app.route("/login", methods=["POST"])
async def login():
email = request.json.get("email")
password = request.json.get("password")
user = authenticate_user(email, password)
if not user:
return jsonify({"error": "Invalid credentials"}), 401
# !! Everything above here stays the same, everything below changes
result = await auth_client.session.create(
user_id=user.user_id,
# Make sure to pass IP and User-Agent for better logging and protection
ip_address=request.remote_addr,
user_agent=request.headers.get("user-agent"),
)
if not result.ok:
return jsonify({"error": "Could not create session"}), 500
# We recommend using a Secure, HttpOnly cookie for the session token
response = jsonify({"user": user})
response.set_cookie("sessionToken", result.data.session_token, httponly=True, secure=True, samesite="lax")
return response
This stores a BYO session token in a cookie after the user logs in. Next, we'll need to update our login_required decorator to validate that cookie.
4. Protect routes by validating the cookie
from propelauth_byo import is_err
from functools import wraps
def login_required(f):
@wraps(f)
async def decorated_function(*args, **kwargs):
token = request.cookies.get("sessionToken")
if not token:
return jsonify({"error": "Unauthorized"}), 401
validation = await auth_client.session.validate(
session_token=token,
# If you passed these to create, you must pass them to validate
ip_address=request.remote_addr,
user_agent=request.headers.get("user-agent"),
)
if is_err(validation):
return jsonify({"error": "Unauthorized"}), 401
# Store the user_id in g for use in the route
g.current_user = {"user_id": validation.data.user_id}
return await f(*args, **kwargs)
return decorated_function
And that's it! It may not look like much, but with just those changes, you've added:
- Detailed audit logging for all sessions
- Automatic invalidation on suspicious user agent / IP address changes
- A dashboard to inspect active sessions, expiration reasons, and manage sessions
However, like we said at the start, session management tends to get more complicated. Let's look at some common patterns and how BYO makes them easy.
5. Adding session rotation
Session rotation limits the damage from session hijacking by rotating tokens periodically. If an attacker steals a token, it will only be valid for a short window before being replaced. Even better, we can also detect that an old token is being used and immediately invalidate the session.
So how do we implement this? We swap the validate function for validate_and_refresh. validate_and_refresh will periodically return a new_session_token that you should use instead.
validation = await auth_client.session.validate_and_refresh(
session_token=request.cookies.get("sessionToken"),
# ... same as before
)
if is_err(validation):
return jsonify({"error": "Unauthorized"}), 401
# Set a new cookie if we got a new token
if validation.data.new_session_token is not None:
response = make_response()
response.set_cookie("sessionToken", validation.data.new_session_token, httponly=True, secure=True, samesite="lax")
# You'll need to handle this response appropriately in your decorator
6. Per-organization settings with tags
Not all sessions are created equal. You might have one customer that complains about needing to log in too often and another that demands short sessions for security.
BYO lets you define session tags, attach them to sessions, and use them to customize session behavior. Session tags are strings of the form {type}:{value} like role:root or org:acme-corp or login_type:sso.
In your session config, you can define rules per tag, for example:
{
"defaults": {
"absolute_lifetime_secs": 1209600 // 14 days
},
"tags": [
{
"tag": "role:root",
"absolute_lifetime_secs": 14400, // 4 hours
"inactivity_timeout_secs": 900, // 15 minutes
"disallow_ip_address_changes": true
},
{
"tag": "org:acme-corp",
"absolute_lifetime_secs": 43200 // 12 hours
},
{
"tag": "org:bigco",
"ip_allowlist": ["203.0.113.0/24"] // office IPs only
}
]
}
Then, you just need to add the appropriate tag when creating a session. For example, if you have a org_slug field on your user model:
tags = [f"org:{user.org_slug}"]
if user.org_role == Roles.ROOT:
tags.append("role:root")
result = await auth_client.session.create(
user_id=user.user_id,
ip_address=request.remote_addr,
user_agent=request.headers.get("user-agent"),
tags=tags
)
If there are any conflicts between tags, you can use the tag_priority field to resolve them (e.g. role comes before org).
7. "Sign in detected from a new device"
If you want to give your users more peace of mind, you can notify them when a sign-in is detected from a new device. This requires a bit more work on both the frontend and backend, but BYO makes it straightforward.
On the frontend, you need some indication of what a "device" is. The most robust way to do this is using the Demonstrating Proof-of-Possession (DPoP) OAuth spec. DPoP solves this problem by using the Web Crypto API to generate a public and private key in the browser. The private key never leaves the browser (it's set to be unextractable), so when we use that private key, we can be sure it's the same device.
This is the foundation for a lot of powerful features. The backend, at any point, can say "Prove to me that you have the private key by signing this challenge." If the frontend can sign it, we know it's the same device. Otherwise, it's a new device.
This can be tricky to get right, so we provide a lightweight frontend library @propelauth/byo-javascript that handles this for you.
We first need to set up an endpoint that generates these challenges:
@app.route("/api/device-challenge", methods=["GET"])
async def device_challenge_endpoint():
# Recommended: tie the challenge to the user's IP and User-Agent
response = await auth_client.session.device.create_challenge(
ip_address=request.remote_addr,
user_agent=request.headers.get("user-agent")
)
if is_ok(response):
return jsonify({
"deviceChallenge": response.data.device_challenge,
"expiresAt": response.data.expires_at
})
else:
print("Error creating device challenge:", response.error)
return jsonify({"error": "Failed to create device challenge"}), 500
Then, on the frontend, we need to initialize the @propelauth/byo-javascript library. This library will handle creating the public/private keys, storing them securely, fetching challenges from your backend, and signing them.
initFetchWithDevice({
fetchChallenge: async () => {
const response = await fetch("/api/device-challenge");
const jsonResponse = await response.json();
return {
deviceChallenge: jsonResponse.deviceChallenge,
expiresAt: new Date(jsonResponse.expiresAt * 1000),
};
},
getChallengeFromFailedResponse: async response => {
// We'll use this later on, but if the backend is ever suspicious, it'll return a 425
// error with a new challenge we can use to verify the device.
if (response.status === 425) {
const jsonResponse = await response.json();
return {
deviceChallenge: jsonResponse.deviceChallenge,
expiresAt: new Date(jsonResponse.expiresAt * 1000),
};
}
},
fallbackBehavior: "fail",
});
Once that's initialized, we can use the fetchWithDevice function, which is a drop-in replacement for fetch that automatically handles the device challenge and signing for you.
const response = await fetchWithDevice("/login", {
method: "POST",
body: JSON.stringify({ email, password }),
headers: { "Content-Type": "application/json" },
});
And then, back in our login route (named /login), we can use the signed challenge to register the device with BYO:
session = await auth_client.session.create(
user_id=user.user_id,
ip_address=request.remote_addr,
user_agent=request.headers.get("user-agent"),
# Note the new device_registration field
device_registration=DeviceRegistration(
# This header is added by fetchWithDevice
signed_device_challenge=request.headers.get("dpop"),
remember_device=True
)
)
if is_err(session):
return jsonify({"error": "Could not create session"}), 500
# Use session.data.new_device_detected to notify the user
if session.data.new_device_detected:
await send_new_device_email(user.email)
Putting this all together:
- When a user loads the page, a public/private key pair is generated and stored in the browser.
- In the background, the
@propelauth/byo-javascriptlibrary periodically fetches a device challenge from your backend. - When the user logs in, the library will sign the challenge with the private key and include it in the login request.
- That signed challenge is sent to BYO, which verifies it and registers the device (to be technically precise, it's registering the public key).
- If it's a new device, BYO will return
new_device_detectedin the session creation response, and you can notify the user.
But that's not all we can do with this foundation...
8. Protect against stolen cookies with device verification
In our login flow, we registered the device with BYO. But we only really used it to notify the user of if that was the first time we'd seen that device.
As an extra layer of security, we can require that the device prove it has the private key on every request. This way, even in a session hijacking scenario where an attacker steals a cookie, they can't use it without also having the private key.
The best part is that we already have everything we need in place. On the frontend, we just replace our fetch calls with fetchWithDevice so it automatically signs the challenge for us. On the backend, we just need to pass our signed challenge to the validate call:
validation = await auth_client.session.validate(
session_token=request.cookies.get("sessionToken"),
ip_address=request.remote_addr,
user_agent=request.headers.get("user-agent"),
# New field for device verification
device_verification=DeviceVerification(
signed_device_challenge=request.headers.get("dpop")
)
# Note, if you want to adopt device verification gradually, you can also use
# this field to skip verification for select endpoints
# ignore_device_for_verification=True
)
if is_err(validation):
# In some cases, BYO will say "Yeah, this is a properly signed challenge, but it's out of date
# or the user's IP changed, so we need a new challenge."
# We can respond with a 425 and the challenge
# and the frontend library will automatically handle it and retry the request.
if validation.error.type == "NewDeviceChallengeRequired":
return jsonify({
"deviceChallenge": validation.error.details.device_challenge,
"expiresAt": validation.error.details.expires_at,
}), 425
return jsonify({"error": "Unauthorized"}), 401
Putting it all together
If we stop here for a second we can see how powerful this is. In about ~100 lines of code, we've added protections like:
- If an attacker steals a session token...
- Because of session rotation, it will only be valid for a short window
- Because of device verification, they also need the private key from the user's device to use it
- If an attacker steals a signed device challenge...
- The challenge itself is only valid for a short window
- The challenge is tied to the user's IP and User-Agent, so it can't be replayed from a different location
- We didn't cover this, but you can also you can also pass in a
methodandurlto device verification so the challeng
- If an attacker steals a user's credentials...
- The user will be notified when they log in from a new device, allowing them to take action
And while all of that will make your security team happy, we've also made our lives significantly easier by providing:
- A dashboard to inspect active sessions, expiration reasons, and manage sessions
- Detailed, structured audit logs for all session activity
- The ability to customize session behavior per customer with tags
When customers write in with session requests, your team can update their config without bothering the engineering team.
Your team gets to focus on building features, not maintaining complex session logic.
