Skip to main content

Command Palette

Search for a command to run...

Your access token lifetime means two different things, depending on one database read

Why a server-side session row turns ACCESS_TOKEN_EXPIRE_MINUTES from "session length" into "revocation delay" — and the ten places our own docs have not caught up

Updated
25 min readView as Markdown
J
Solo dev building SOIT, an open-source self-hosted agent platform. Into self-hosting, LLM infra, and open source.

The short version

In a self-hosted deployment, "how long does a login last" is never one number. In ours it is two knobs you can turn, six hard-coded deadlines you cannot, and one revocation path:

Thing Lifetime Configurable What happens at the end
Access token 30 minutes ACCESS_TOKEN_EXPIRE_MINUTES The browser silently swaps it; nobody notices
Session (refresh token) 14 days REFRESH_TOKEN_EXPIRE_DAYS Password required again
Second-factor challenge 5 minutes ❌ hard-coded Start the sign-in over
Password reset link 30 minutes ❌ hard-coded Request another mail
Email verification link 24 hours ❌ hard-coded Request another mail
Workspace invitation 14 days ❌ hard-coded The inviter resends
API key 1–365 days, mandatory at creation per key The call gets a 401
Ending one device That device's next request is a 401

Only the second of the two knobs is the session length. The first one is not — even though our own documentation still explains it as if it were. That is item ③ in the last section.

1. The commit that set it to eight hours

2026-08-06, commit 69be399, titled fix(deploy): default the access token lifetime to one workday. The message says exactly why:

Access tokens expired after a fixed 30 minutes and there is no refresh
flow, so self-hosted users were silently logged out mid-session.

With no refresh flow, the access token is the session. When it expires the person is out, and not politely — there is no "your session ended" dialog, just a 401 on the next click. So the default became 480 minutes, one working day, threaded through both compose profiles.

That was the right call at the time, and the message also names the cost: every sign-in became an eight-hour grant that nobody could take back. A JWT that has been signed is not something the server knows about. There is no row to flip. Your options are to wait it out or to rotate the signing key and drop everybody at once.

For a self-hosted internal platform that cost is not academic. Someone leaves the company. A laptop goes missing. An engineer signs in on a customer's machine to demo something. In each of those the thing you want is "end that one login", and the system's only answer was: eight hours.

2. The commit that set it back to thirty minutes

2026-08-30, commit 09dbf7e, titled feat(identity): make a sign-out mean something, and stop logging people out. 38 files, +1439/-56. Four things, reordered by how much they matter:

  1. A sign-in opens a session row, and the access token carries a sid claim naming it.
  2. Every authenticated request checks that the session is still live, so revocation lands on the next request.
  3. Refresh tokens rotate on every use, and only the hash is stored.
  4. The console renews silently, one renewal serving every request that failed together.

The moment 1 and 2 landed, ACCESS_TOKEN_EXPIRE_MINUTES changed meaning. It no longer decides how often a person gets kicked out — REFRESH_TOKEN_EXPIRE_DAYS plus the refresh flow decides that. It now decides exactly one thing: how long a revoked login keeps limping. So it went back to 30, and the commit message says so:

Access tokens drop to 30 minutes because they no longer bound the session --
they bound how long a revoked session keeps working.

One setting, two commits, two meanings. That is the reason for this post. If you see ACCESS_TOKEN_EXPIRE_MINUTES in a deployment, work out which of the two systems you are looking at before you decide whether to raise or lower it.

(That commit message now needs a small asterisk of its own — see item ③. For a token that carries a sid, revocation is immediate; the sentence describes the tokens that do not.)

3. Why checking the session on every request is nearly free

"JWTs cannot be revoked" is shorthand for "signature-only JWTs cannot be revoked". The moment your request path contains any server-side lookup, revocation is back on the table. The lookup is the price.

We did not add a lookup. We put the check on one that was already happening (server/app/modules/identity/infra/workspace_access.py:30–37):

When the caller's token names a session, that session must still be
live. It is checked here rather than in a separate lookup because this
is already the one database read every authenticated request makes, and
checking it at refresh time alone would leave a signed-out token working
until it expired.

The precondition — true for more systems than people assume — is that authentication here was never "decode the JWT and wave it through". Every authenticated request has to answer is this person still a member of this tenant and this workspace, and what are their quotas. That means reading the database. Given that the connection is open and the transaction has started, one more primary-key lookup costs approximately nothing.

Counted in the order DatabaseWorkspaceAccessResolver.resolve performs them (workspace_access.py:23–97):

# Read For
1 user_sessions by primary key Is the session still live (only when the token has a sid)
2 Tenant membership Still in this tenant
3 Workspace membership Still in this workspace
4 Tenant Tenant-level quotas
5 Workspace Workspace-level quotas, overriding the tenant's
6 MFA enrolment (conditional) Only when the workspace requires a second factor

The session check is the first of those, and it is an equality read on a primary key. What it buys: end a device in the console and that device's next request is a 401. No 30-minute wait, no denylist to keep warm.

One detail from row 6 worth stealing: when a workspace requires a second factor and the caller has not enrolled, the resolver does not raise "you are not a member". It raises a 403 carrying reason: mfa_required (workspace_access.py:63–72). The two look alike from the server and are completely different from the browser — the first can only render "no access", the second can send the person to the enrolment page.

4. Why shipping this did not sign everybody out

The scariest part of changing authentication is the instant it deploys. The handling here is simple and worth copying: sid is an optional claim, and a token without one is honoured until it expires.

It is written into the docstring of create_access_token (server/app/kernel/identity/auth.py:53–58):

session_id: Session this token belongs to. Naming it lets a
    sign-out end the access it granted; a token without one is
    accepted until it expires, so upgrading does not sign
    everybody out.

And _require_live_session is only reached when session_id is present (workspace_access.py:45–46, :100–107). The migration window closes by itself: an old token lives at most ACCESS_TOKEN_EXPIRE_MINUTES longer, then the client trades its refresh token for a new one — and the new one carries a sid. Nobody is kicked, and nobody is stuck under the old rules forever.

There is a precondition: at the moment of the upgrade, clients need a refresh token to trade. Before 08-30 there was no refresh flow at all, so in practice everyone signed in again within eight hours and picked up a session naturally. Acceptable.

5. Fourteen days is a hard cap, not a sliding window

This is the one people assume wrong. A session's expires_at is computed once, at issue time (service.py:405–429):

expires_at=utc_now() + timedelta(days=self._refresh_token_days()),

I went looking for every other write to that column on the session path. There is none. _issue_session is the only writer; everything else reads it. Refreshing does not extend it. refresh_session rotates the token and updates last_seen_at, workspace_id, user_agent and ip_address (service.py:490–498) — and deliberately not expires_at.

So REFRESH_TOKEN_EXPIRE_DAYS=14 means: fourteen days from the last time you typed your password, no matter how active you were in between. It does not mean "expires after fourteen idle days".

That is a trade-off, and I think it was chosen correctly. A sliding window means a machine that is never turned off never has to prove again that it is still you. A hard cap gives the whole system an upper bound: no login lives longer than fourteen days. The cost is that active users get interrupted too, at a moment nobody can predict — it depends on what time of day they signed in two weeks ago.

Want a tighter bound? Lower it. Want "everybody signs in again on Monday morning"? There is no way to express that today (item ⑥).

6. Refresh tokens: hash only, rotated every time

The session row does not store the refresh token. It stores its SHA-256 (service.py:400–402, :424), the same rule API keys follow. The plaintext is handed to the client once and the server can never produce it again — the model comment states the purpose outright: a stolen database cannot be replayed as a sign-in (models.py:365–367).

Every refresh mints a new one (service.py:490–491). The old hash is overwritten, so the old token stops matching immediately. Which gives a genuinely useful property: a refresh token is good for exactly one use.

What happens on the second use is where the docstring, the deployment guide, the commit message and even the test name all say one thing and the code does another. That is item ④, and it is the most valuable thing I found this time.

7. How the browser hides the expiry

The other half lives in web/app/utils/request.ts. Three pieces add up to silent renewal.

(a) Retry once after a 401 (:206–230):

async function retryWithRefreshedToken(error: any): Promise<any | null> {
  const config = error?.config as (...)
  if (!config || error?.response?.status !== 401) return null
  if (config.skipAuthRefresh || config._retriedAfterRefresh) return null
  if (!storedRefreshToken()) return null
  const token = await refreshAccessToken()
  if (!token) return null
  config._retriedAfterRefresh = true
  config.headers = { ...(config.headers || {}), Authorization: `Bearer ${token}` }
  return request.request(config)
}

Two loop guards: the refresh call carries skipAuthRefresh, and a replayed request carries _retriedAfterRefresh.

(b) One refresh at a time (:145–183). The comment explains why it is mandatory rather than nice:

A page loads a dozen requests at once, and an expired token fails all of
them together. Without this, each failure would spend the same refresh
token, and every attempt after the first would look like a replay

A page fires a dozen requests; an expired token fails all of them at once. If each failure refreshed on its own, they would spend the same refresh token a dozen times, and rotation guarantees only the first can win. A module-level refreshInFlight promise collapses them into one.

(c) Give up and go to the sign-in page (:186–203). AUTO_REDIRECT_UNAUTHORIZED is a hard-coded true (:20); after a 401 it waits a second, clears local storage, and navigates to /sign-in with the current route in a redirect parameter.

Put that together with the server-side check from section 3 and you can trace exactly what happens on a device you just ended from the console: next request 401 (session revoked) → interceptor tries to refresh → the refresh is refused too (_session_is_live fails) → null → the 401 surfaces → local storage cleared, sign-in page. One request cycle. No token has to expire for any of it.

While we are here, the honest version of the storage trade-off: both tokens live in localStorage (auth-session.ts:1–22). That survives a page reload and is simple to implement; it also means one XSS is worth a session of up to fourteen days. HttpOnly cookies would close that and open CSRF and cross-site deployment questions instead. We picked the first, and that is a choice that deserves to be written down rather than defaulted into.

8. One path does not get any of this: SSE

This one matters specifically for an agent runtime, so it gets its own section.

Running an agent or following a workflow in the console is a Server-Sent Events stream (@microsoft/fetch-event-source), and that path does not go through the axios interceptor above. In request.ts:430–470, buildAuthHeaders() is called once while opening the connection, and that header is then fixed for the life of the stream. onerror records the error and rethrows (:479–487) — and in fetch-event-source, throwing from onerror means stop retrying.

Three consequences, pointing in different directions:

  • An open stream does not die when the token expires. Authentication happens when the request is accepted; the connection is not re-checked afterwards. A 40-minute agent run does not drop halfway because of a 30-minute token. That is good.
  • But ending that device does not cut the stream either. Revocation only affects new requests; a stream already pushing events keeps pushing until it finishes on its own.
  • And if the token is already expired when the stream opens, the stream just fails — no refresh, no retry. A tab that sat open for half an hour hits exactly this on the first click of "Run". An ordinary request would have been rescued silently. This one is not.

The third is a real gap. The workaround is blunt and works: fire one cheap ordinary request to renew the token before opening the stream.

9. If you are self-hosting, which number should you touch

A table by threat model beats a recommended value:

What worries you Which knob Set it to Cost
A lost device must be cut off now none End that device in the console None; its next request fails
Nobody will click that button ACCESS_TOKEN_EXPIRE_MINUTES 5–15 minutes More refreshes, each one a write
"Re-authenticate every N days" compliance REFRESH_TOKEN_EXPIRE_DAYS N Interrupts at an unpredictable moment
Shared or kiosk machines REFRESH_TOKEN_EXPIRE_DAYS 1 Password every day
Busy server, fewer database reads raise ACCESS_TOKEN_EXPIRE_MINUTES ⚠ Does not work: the session check runs per request, not per refresh

That last row is the one people get wrong. Lengthening the access token does not reduce database reads, because every request reads membership anyway and the session check rides on that read. The only thing lengthening it does is keep pre-upgrade tokens without a sid alive longer, which is the opposite of what you want.

Two operational notes:

  • SECRET_KEY must be changed, and production will stop you. Shorter than 32 characters, or equal to either known placeholder, and validate_runtime_requirements() fails at startup (settings.py:550–555). Fail-closed, not a warning in a log.
  • Mail is off by default (SYSTEM_MAIL_ENABLED=false), and that interacts directly with session length. Fourteen days in, everyone must type their password again — and the only route back from a forgotten password is a reset mail. With mail off, bootstrap_admin.py prints User already exists. Skipping bootstrap. and exits (:30–33). There is no second path inside the product. Either turn mail on, or accept that "forgot password" means "write SQL".

10. The six deadlines you never see in config

All of them are constants (service.py:91–103), nothing to do with your deployment:

MFA_CHALLENGE_PURPOSE = "mfa_challenge"
MFA_CHALLENGE_MINUTES = 5
PASSWORD_RESET_MINUTES = 30
EMAIL_VERIFICATION_MINUTES = 60 * 24
INVITATION_DAYS = 14

The second-factor challenge is the interesting one. The state between "password accepted" and "second factor proved" is carried by a stateless JWT with a purpose: mfa_challenge claim (service.py:1131–1143). The point is that this ticket cannot be used as an access token — the authentication entry point rejects any token carrying a purpose (context_resolver.py:88–92):

# A token minted for one step of sign-in authorizes nothing. Without
# this, presenting the challenge token as a bearer would make the
# second factor optional for anyone who noticed.
if payload.get("purpose"):
    raise UnauthorizedError("Token cannot be used to authorize a request")

"A credential for a step of sign-in is not a credential for being signed in" is the box most hand-rolled MFA implementations forget to tick. The price is that this ticket is not revocable — it is stateless, with nowhere to mark it spent, and it is good for the full five minutes. That is item ⑨.

API keys are a separate path entirely: no session, their own row lookup, their own expiry check, their own scopes (context_resolver.py:145–175). And the lifetime is mandatory at creation, between 1 and 365 days (schemas.py:408–412). There is no "never expires" option. Mildly annoying for automation; "long-lived credentials get reissued" is a default I will defend.

11. Rotating SECRET_KEY no longer signs everybody out

An operational note that flipped when the session table landed.

Before: every access token was signed with SECRET_KEY. Rotate it and every token fails verification — that was the global sign-out button.

Now: rotate it and every access token still fails at once. But the client takes the 401 and refreshes, and refresh_session never looks at the access token — it takes the refresh token and queries the database (service.py:460–468). Refresh tokens are random strings stored as hashes; they have nothing to do with the signing key. The client gets back a token signed with the new key and the user notices nothing at all.

The good news is that key rotation is no longer a mass interruption. The bad news is that you can no longer use it as an emergency eject button. And a real "sign everybody out" does not exist: revoke_all_sessions only touches the caller's own sessions (service.py:536–551), and the only routes are the three under /me/sessions (router.py:257–277). To cut off someone who left the company today you close their account (execute_account_deletion ends all their sessions, service.py:640–660), remove them from the tenant (membership is read on every request, so the next one is a 403), or go into the database.

12. What you can see: the security pane

With sessions in place, the console lists signed-in devices: user agent, IP, last activity, each with an End button — except the current one, which deliberately has no button (the commit message: signing yourself out of the page you are on is a trap). "Sign out everywhere" keeps the current device by default (settings.tsx:309–317 calls revokeAllSessions(true)).

The "last active" column in the member list comes from the same rows: last_seen_for_users takes the maximum last_seen_at across a user's sessions (repository.py:468–483). Mind the resolution — last_seen_at is written only on refresh, so it is accurate to roughly one access token lifetime (30 minutes), not to the last click.

What you cannot see, so nobody misreads the pane: ended sessions are not listed (list_by_user defaults to include_ended=False, and include_ended=True has no call site anywhere in the repository). This is a list of currently signed-in devices, not a sign-in history. And signing in is not audited at all — the audit table has identity.session.revoked and no event type for a login.

13. Ten things that do not line up

By convention, this section is about our own problems.

① The "Session timeout" dropdown in the console is decorative. web/app/console/routes/settings.tsx:1105–1116 renders a 12 hours / 24 hours / 7 days select with defaultValue="12 hours" and no onChange, no save, no read from the backend. The comment beside it is honest:

BACKEND-PENDING: session lifetime is an instance setting
(ACCESS_TOKEN_EXPIRE_MINUTES), not a workspace one. Not built
rather than withheld: it needs a per-workspace override the
token issuer would have to read.

Worse than unwired: none of the three options matches either real knob. The real values are 30 minutes and 14 days; the dropdown offers 12 hours, 24 hours, 7 days. An administrator reading that screen will reasonably conclude their sessions last twelve hours. Impact: misinformation. Workaround: edit .env. I intend to open an issue for this; it was not filed when this was written, so there is no link in the text.

② "Log out" in the account menu only clears the browser. Both entry points (web/app/components/common/nav-user.tsx:31–36, web/app/console/shell/icon-rail.tsx:81–86) do the same four things: clear the query cache, clear the user store, clear local storage, navigate to /sign-in. Neither calls revokeSession or /me/sessions/revoke-all.

So: you click log out, and the session row stays active. It keeps appearing in the security pane's device list. Its refresh token stays valid for the rest of the fourteen days — that string was merely deleted from this browser's local storage. Impact: "sign out" means two different things in two places — the End button in the security pane really revokes, the Log out item does not. Workaround: use the security pane. Also intend to open an issue; the fix looks like a handful of lines (fire a revoke first, proceed either way).

③ Four places in docs and comments still describe the old revocation semantics. All four say the same thing: after revocation, an already-issued access token keeps working until it expires.

  • server/app/settings/settings.py:73–78
  • .env.example:15–18
  • server/.env.example:33–36
  • docs/deployment/production-profile.md:46–50 — verbatim: an access token already issued keeps working until it expires, even after the session behind it was ended

And the code in section 3 exists precisely to make that untrue. A token carrying a sid gets a 401 on the next request after its session is revoked. Those four notes were written before or alongside the session table and never updated. Impact: a reader shortens the access token lifetime to reduce a "revocation delay" that is already zero. Workaround: trust the code. Also intend to open an issue.

④ "Presenting a spent refresh token ends the session" is documented and not implemented — and the test is named after the behaviour it does not have. This is the big one, so here it is in full.

Three places promise it:

  • service.py:454–457: presenting a rotated-out token ends the session
  • docs/deployment/production-profile.md:52–55: presenting a spent one ends the session, so a stolen token is usable at most until the real client next renews
  • commit 09dbf7e: A spent one is a replay, and the answer to a replay is to end the session

The code (service.py:462–468):

session = self.session_repo.get_by_refresh_hash(
    self._hash_refresh_token(refresh_token)
)
if session is None:
    raise UnauthorizedError("Invalid refresh token")

After rotation the old token's hash has been overwritten. No row in the database knows it any more. Presenting it produces "no match → 401", the session is untouched, and nothing anywhere records that a replay happened.

The test file is blunter about it (server/tests/unit/test_user_sessions.py:65–77): the function is called test_replaying_a_rotated_token_ends_the_session, the assertion is assert len(service.session_repo.list_by_user(user.id)) == 1, and the comment reads the live session is untouched by a failed replay of the old one. The name and the body contradict each other, and the body is the accurate one.

Impact: when a refresh token is stolen, whoever uses it first wins. If the attacker refreshes first, they hold the rotated token and the session; the real client's next refresh fails with a 401 and they end up signing in again, believing they were simply "logged out". Nobody is told a replay occurred. Workaround: real replay detection needs a stored previous-generation hash, or a monotonic counter per session. Also intend to open an issue — and I consider it more serious than ① or ②, because a security property written into deployment documentation is something other people will cite as fact.

⑤ The expired status is never written, and session rows are never cleaned up. The model documents three values for status — active, revoked, expired (models.py:385–386) — and nothing in the repository ever assigns "expired". Expiry is evaluated at read time, in two places (_session_is_live, _require_live_session).

Alongside it sits ix_user_sessions_expiry, a composite index on (status, expires_at) (models.py:373): an index built for a sweeper that does not exist. No worker or script deletes or archives expired sessions. Impact: user_sessions grows monotonically with sign-ins; an instance a year old carries a pile of rows that died a fortnight in. Workaround: a scheduled DELETE of long-expired rows is safe — nothing reads them.

⑥ There is no idle timeout, and no way to say "everyone signs in on Monday". last_seen_at is recorded (on refresh) but participates in no decision — it drives ordering and the "last active" column, nothing else. So the common compliance line "a login unused for 30 days must expire" can only be approximated by shortening REFRESH_TOKEN_EXPIRE_DAYS, which is an absolute cap rather than an idle window; the two hit active users very differently. Impact: deployments with that requirement cannot express it. Workaround: shorten the cap, or revoke stale rows on a schedule.

⑦ Changing your password does not end other sessions; resetting it does.

  • complete_password_reset (service.py:808–821) walks every session for that user and ends them, with the reasoning in the docstring: a reset is what you do when you think the account is compromised.
  • change_password (service.py:1517–1528) verifies the old password, writes the new hash, and returns. It touches no sessions at all.

Impact: someone who changes their password because they think somebody saw it leaves that somebody signed in — while almost certainly believing the change kicked them out. Workaround: click "sign out of other devices" right after.

⑧ Signing in is not audited; signing out is. The audit table carries identity.session.revoked (service.py:553–575), identity.mfa.changed, identity.account.closed and others. There is no event type for a login or a new session. For a platform whose pitch is governance that asymmetry stands out: you can find out who ended whose session, but not who signed in, when, or from where. The session row holds exactly that information (created_at, ip_address, user_agent) — it just never reaches the audit stream. Impact: sign-in activity is outside audit search and export. Workaround: query user_sessions directly.

/login and /refresh are not rate limited. The application registers four middlewares (tracing, error handling, response envelope, request id) plus CORS (main.py:274–292) — none of them a limiter. The production Caddyfile is 28 lines and contains no limit_req. Every setting in the system named rate_limit is an LLM or tool-call quota and has nothing to do with sign-in.

Related, same item: the second-factor challenge is a five-minute stateless JWT with no spent-marker, and TOTP codes themselves are not replay-protected (totp.py:63–86 compares the candidates within ±drift steps and never records which step was used). Impact: brute-force and code-replay protection in our default stack is entirely the deployer's reverse proxy, and the documentation does not say so. Workaround: rate-limit /api/v1/login, /api/v1/refresh and /api/v1/login/mfa at the gateway.

⑩ A multi-tab refresh race can bounce someone to the sign-in page. This is an inference, not an observation. The single-flight lock from section 7 is a module-level variable (request.ts:153), so its scope is one tab. Open two console tabs, let the token expire in both, then use both: each reads the same refresh token from local storage, the first wins and writes the new one back, the second gets a 401, refreshAccessToken returns null, the 401 surfaces, and AUTO_REDIRECT_UNAUTHORIZED sends that tab to the sign-in page.

I did not reproduce this, so it is a conclusion from reading code. It is here because it is exactly the problem item ④ would amplify if the documented behaviour were actually implemented — at that point the loser of the race would not be one tab, it would be the whole session. Workaround, if it does happen: share the refresh result between tabs via a storage event or a BroadcastChannel.

Disclosure

  • Nothing was run for this piece. Every conclusion comes from reading code, git history and tests at commit fb46f20. I did not stand up an instance to watch a revoked session get a 401 on the next request, even though I believe the code path is unambiguous. Item ⑩ is explicitly an inference.
  • Community edition only. Whatever the Enterprise and Cloud builds add on top of this is out of scope.
  • "Nearly free" is an architectural judgement, not a measurement. Section 3 argues the marginal cost is close to zero because the check rides on a read that already happens and is a primary-key lookup. I did not benchmark it.
  • None of the ten items is a security incident. They are documentation drifting from implementation, and features that are not finished.
  • Disclosure: I maintain SOIT.

One sentence

Without a server-side session record, ACCESS_TOKEN_EXPIRE_MINUTES is your session length and every value hurts; with one, it is only the ceiling on revocation delay, so shorter is better and the session length belongs to a different setting. Which of the two you have is decided not by the setting but by whether your request path already performs a lookup — and if it already reads membership, you have paid for that lookup already.

Come and find the holes

The repository is github.com/soit-ai/soit, and every claim above is checkable:

  1. git show 69be399 and git show 09dbf7e are the two commits; both messages are shorter and sharper than this post.
  2. The "riding along" comment is in server/app/modules/identity/infra/workspace_access.py; read it together with resolve underneath.
  3. The self-contradicting test in item ④ is in server/tests/unit/test_user_sessions.py — the function name and the assertion are three lines apart.

If any of this is wrong — especially if one of those ten items is me missing an implementation that does exist — please open an issue and say so. I would rather learn which parts do not line up than be told the design reads well.