Skip to content

Session.refresh() raises KeyError('sealed_session') and reports reason "'sealed_session'" #666

@s-lopez

Description

@s-lopez

Hello team! I stumbled across a possible bug while experimenting with AuthKit and I wanted to share the insights with you. This report is AI-curated and I hope it's useful and complete. Please let me know if you need any other information from my side. Thank you.

Summary

Refreshing a sealed AuthKit session via the documented load_sealed_session(...).refresh() flow always fails. The underlying /user_management/authenticate call returns 200 OK, but Session.refresh() then raises KeyError('sealed_session') internally, swallows it, and returns a RefreshWithSessionCookieErrorResponse whose reason is the literal string "'sealed_session'" — not a member of AuthenticateWithSessionCookieFailureReason. In a cookie-session web app this logs the user out the first time their access token needs refreshing.

Environment: workos 8.0.0 · Python 3.14.4 · macOS 15.7.7 · WorkOS Sandbox

Reproduction

Self-contained — needs only WorkOS credentials and one user, read from env:

import os
from workos import WorkOSClient
from workos.session import seal_data

client = WorkOSClient(
    api_key=os.environ["WORKOS_API_KEY"],
    client_id=os.environ["WORKOS_CLIENT_ID"],
)
cookie_password = os.environ["WORKOS_COOKIE_PASSWORD"]  # any string >= 32 chars

# A valid, unused refresh token. Minted here via the password grant (any WorkOS
# user with password auth enabled); or substitute an existing refresh token.
refresh_token = client.user_management.authenticate_with_password(
    email=os.environ["WORKOS_USER_EMAIL"],
    password=os.environ["WORKOS_USER_PASSWORD"],
).refresh_token

# refresh() only needs a refresh_token + a truthy user in the sealed payload to
# reach the network call, and it ignores access-token expiry — so this
# reproduces immediately, no waiting required.
sealed = seal_data({"refresh_token": refresh_token, "user": {"id": "x"}}, cookie_password)
session = client.user_management.load_sealed_session(
    session_data=sealed, cookie_password=cookie_password,
)

result = session.refresh()
print(result.authenticated, repr(result.reason))
# -> False "'sealed_session'"

Expected behaviour: a RefreshWithSessionCookieSuccessResponse with a new sealed_session, or a documented failure reason.
Actual behaviour: authenticated=False, reason="'sealed_session'".

Root cause

In src/workos/session.py, Session.refresh() (~lines 312–391 on main):

  1. POSTs a refresh_token grant with
    session={"seal_session": True, "cookie_password": ...}HTTP 200.
  2. Reads auth_response["sealed_session"] via a hard key access (~lines 361, 377).
  3. The 200 body has no sealed_session — its keys are
    ['access_token', 'authentication_method', 'refresh_token', 'user'] — so this
    raises KeyError('sealed_session').
  4. The surrounding except Exception (~line 388) maps it with
    _map_refresh_exception_to_reason, which has no KeyError branch and falls
    back to return str(exc)"'sealed_session'".

Suggested fix

Guard the read with auth_response.get("sealed_session") and return a documented reason (or raise a typed error) when it is absent; and confirm the refresh_token grant request actually elicits sealed_session from the API.

Workaround

Refresh by hand with the typed method and re-seal the cookie, instead of session.refresh():

from workos.session import seal_data, unseal_data

stored = unseal_data(sealed_cookie_value, COOKIE_PASSWORD)
refreshed = client.user_management.authenticate_with_refresh_token(
    refresh_token=stored["refresh_token"],
)
new_sealed_cookie = seal_data(
    {
        "access_token": refreshed.access_token,
        "refresh_token": refreshed.refresh_token,
        "user": refreshed.user.to_dict(),
        "impersonator": refreshed.impersonator.to_dict() if refreshed.impersonator else None,
    },
    COOKIE_PASSWORD,
)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions