┌─ LOG ENTRY ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
LOG_0062026.06.25
[jwt][cookies][security]

Splitting the access token from the refresh token by transport, not just lifetime

// CONTEXT

Wiring up /auth/register, /auth/login, /auth/refresh — needed a token strategy that a JS frontend could use without opening it up to XSS-driven token theft.

// WHAT THE OBVIOUS VERSION WOULD DO

Drop both tokens in localStorage and attach them from JS. It's the default tutorial pattern for a JS frontend — one storage, one code path, done. It also means the token you most need to protect (the 7-day refresh) is sitting in exactly the place an XSS payload can read it.

// THE CODE THAT DID IT

src/ledgercore/api/routers/v1/auth.py:
def _set_refresh_cookie(response: Response, token: str) -> None
    response.set_cookie(
        key="refresh_token",
        value=token,
        httponly=True,
        secure=True,
       samesite="strict"
       max_age=_REFRESH_TOKEN_EXPIRE_SECONDS,
        path="/api/v1/auth/refresh",
    )
paired with the access token just being returned in the JSON body:
return TokenResponse(
  access_token=create_access_token(user),
  expires_in=_ACCESS_EXPIRE,
)

// WHAT I FOUND

The two tokens get deliberately different treatment. The access token (15 min TTL) goes in the response body, so the frontend holds it in memory and attaches it to API calls — short-lived enough that theft via XSS has a small blast radius. The refresh token (7 days) is the one that actually matters to protect long-term, so it never touches JS at all: HttpOnly keeps it unreadable from document.cookie, Secure keeps it off plaintext HTTP, SameSite=Strict blocks it being sent on cross-site requests, and scoping path to /api/v1/auth/refresh means the browser doesn't even attach it to unrelated API calls.

// LIMIT

SameSite=Strict blocks the cookie on any cross-site request, not just top-level navigations — including the fetch() call the frontend makes to hit /auth/refresh. But main.py configures CORS with allow_origins=["*"] and allow_credentials=True, which only makes sense if a cross-origin frontend is expected. Those two decisions contradict each other: if the frontend really is on a different origin, the refresh cookie set here would never actually reach /api/v1/auth/refresh, and refresh would silently fail. Either the frontend is same-site in practice (making the wildcard CORS moot) or this is a live bug waiting to be hit.

// TAKEAWAY

Don't treat "access token" and "refresh token" as the same kind of secret with different expirations — give the long-lived one a transport (HttpOnly cookie) that structurally can't be read by JS, since that's the one an XSS bug would actually want.

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────