PropelAuth Logo
← Back to Blog

Session management in FastAPI: from simple to secure with PropelAuth BYO

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 FastAPI 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 FastAPI app

Prerequisites: An existing FastAPI app with login

We're going to assume that you have a FastAPI application already with a login route that verifies user credentials. Something like this, which we've taken from this FastAPI docs page, note that we're only showing the relevant parts:

app = FastAPI()

# ... abbreviated ...

# This route verifies username/password and returns a session token
@app.post("/token")
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
    user = authenticate_user(fake_users_db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    return Token(access_token=access_token, token_type="bearer")

# This dependency validates session tokens
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    credentials_exception = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except InvalidTokenError:
        raise credentials_exception
    user = get_user(fake_users_db, username=token_data.username)
    if user is None:
        raise credentials_exception
    return user

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 dependency that validates session tokens on protected routes.
  • You can use that dependency in 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).

dashboard

2. Install and configure the BYO client

Now that we have the sidecar running, we can install the BYO client in our FastAPI 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.post("/token")
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
    user = authenticate_user(fake_users_db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)

    # !! Everything above here stays the same, everything below changes
    result = await auth_client.session.create(
        user_id=user.username,

        # Make sure to pass IP and User-Agent for better logging and protection
        ip_address=request.client.host,
        user_agent=request.headers.get("user-agent"),
    )

    if not result.ok:
        raise HTTPException(500, "Could not create session")

    # We recommend using a Secure, HttpOnly cookie for the session token
    response.set_cookie("sessionToken", result.data.session_token, httponly=True, secure=True, samesite="lax")
    return {"user": user}

This stores a BYO session token in a cookie after the user logs in. Next, we'll need to update our get_current_user dependency to validate that cookie.

from propelauth_byo import is_err

async def get_current_user(request: Request):
    credentials_exception = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    token = request.cookies.get("sessionToken")
    if not token:
        raise credentials_exception

    validation = await auth_client.session.validate(
        session_token=token,

        # If you passed these to create, you must pass them to validate
        ip_address=request.client.host,
        user_agent=request.headers.get("user-agent"),
    )

    if is_err(validation):
        raise credentials_exception

    # We're matching the previous example's return type
    return {"sub": validation.data.user_id}

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):
    raise credentials_exception

# Set a new cookie if we got a new token
if validation.data.new_session_token is not None:
    response.set_cookie("sessionToken", validation.data.new_session_token, httponly=True, secure=True, samesite="lax")

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.username,
    ip_address=request.client.host,
    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.get("/api/device-challenge")
async def device_challenge_endpoint(request: Request):
    # Recommended: tie the challenge to the user's IP and User-Agent
    response = await client.session.device.create_challenge(
        ip_address=request.client.host,
        user_agent=request.headers.get("user-agent")
    )

    if is_ok(response):
        return {
            "deviceChallenge": response.data.device_challenge,
            "expiresAt": response.data.expires_at
        }
    else:
        print("Error creating device challenge:", response.error)
        raise HTTPException(status_code=500, detail="Failed to create device challenge")

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("/api/token", {
    method: "POST",
    body: JSON.stringify({ email, password }),
});

And then, back in our login route (named /token), we can use the signed challenge to register the device with BYO:

session = await auth_client.session.create(
    user_id=user.username,
    ip_address=request.client.host,
    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):
    raise HTTPException(500, "Could not create session")

# 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-javascript library 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_detected in 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.client.host,
    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 result.error.type == "NewDeviceChallengeRequired":
        return JSONResponse(
            status_code=425,
            content={
                "deviceChallenge": result.error.details.device_challenge,
                "expiresAt": result.error.details.expires_at,
            },
        )

    raise credentials_exception

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 method and url to 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.

Related Posts