The JWT's exp claim says 30 days, and I took that as the session
lifetime. It is only an outer ceiling. The server also keeps a per-token
whitelist entry in Valkey (jwt:{accountId}:{jti}) whose TTL is
JWT_TIMEOUT_SECONDS — 7200s on this instance — and JwtStrategy.validate
re-sets it on every authenticated request. Two hours idle and the token
is rejected with 29 days still on exp.
Proven, not inferred: the token from yesterday returned 401 at 13.8h old.
The live instance publishes the values unauthenticated at
GET /api/v3/config/public — JWT_TIMEOUT_SECONDS 7200,
JWT_SHOW_TIMEOUT_WARNING_SECONDS 3600, the latter being exactly the
one-hour UI prompt that prompted this investigation.
refresh-session turns out not to be special: it extends through the same
guard as any other route, and uniquely only in returning the remaining
TTL. So the keepalive uses GET /api/v3/me instead, and the server stays
GET-only; the one POST in the repo is in scripts/probe.mjs, where it
reports the idle budget.
JWT_EXTENDED_TIMEOUT_SECONDS (~1 month) exists in the config schema but
is vestigial: privateDevice has no references in the current NestJS
source, and generateJwtAndAddToWhitelist never overrides the TTL.
Also fixes a real breakage this surfaced: TypeScript parameter
properties are rejected by Node's type stripping, so `npm run dev` and
`npm test` both failed on any file reaching them. Rewritten as explicit
fields, and noted in CLAUDE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.0 KiB
Authentication
What this server uses
The jwt cookie from a logged-in browser session, sent verbatim as
Authorization: Bearer <token>. That is the whole mechanism.
This was worth confirming rather than assuming, because the obvious reading of "it's a cookie" leads somewhere much more complicated. Verified live:
Authorization: Bearer <jwt> → 200 # what this server does
Cookie: jwt=<jwt> → 200 # also works
(no auth) → 401
connect.sid, SERVERID and isLoggedIn are not needed — there is no
cookie jar. There is a session to keep alive, though not in the way the
cookies suggest; see the next section.
Token lifetime: two clocks, and the short one is the one that bites
This is the part that is easy to get wrong, because the JWT lies to you by omission. Two independent limits govern the token:
| Clock | Value | Extendable? |
|---|---|---|
| Idle timeout — a whitelist entry in the server's Valkey store | 7200 s (2 hours) | Yes — reset by every authenticated request |
Hard expiry — the JWT's own exp claim |
30 days | No |
The token dies at whichever comes first. Decoding the JWT shows only the second one, which is how "valid for 30 days" becomes a plausible and wrong conclusion.
How the idle timeout works
JwtStrategy.validate() runs on every request behind @JwtAuthentication()
and calls JwtWhitelistAdapter.isWhitelisted(accountId, jti). That method:
- reads Valkey key
jwt:{accountId}:{jti}; if it is gone, throwsUnauthorizedException('Session was expired due to inactivity - autologout.'); - re-sets the key with a fresh
JWT_TIMEOUT_SECONDSTTL.
Step 2 is the whole mechanism. The window slides: any successful API call
buys another two hours. Two hours of silence and the token is gone, with 29
days still left on exp.
Source: apps/server/src/infra/auth-guard/strategy/jwt.strategy.ts and
apps/server/src/infra/jwt-whitelist/adapter/jwt-whitelist.adapter.ts.
The instance publishes these values
Unauthenticated, so you can check them any time:
curl -s "$TSC_URL/api/v3/config/public" | jq '{JWT_TIMEOUT_SECONDS, JWT_SHOW_TIMEOUT_WARNING_SECONDS}'
# { "JWT_TIMEOUT_SECONDS": 7200, "JWT_SHOW_TIMEOUT_WARNING_SECONDS": 3600 }
JWT_SHOW_TIMEOUT_WARNING_SECONDS: 3600 is exactly the web UI's behaviour: it
warns when 3600 s of the 7200 s budget remain — i.e. after one hour of
inactivity — and offers "Sitzung verlängern". Missing that prompt really does
log you out; it is not just the frontend discarding the token.
Verified empirically
A token issued 2026-09-11T21:12Z was last used at ~21:47Z. At 2026-09-12T11:00Z
— 13.8 hours later, with 29 days left on exp — GET /api/v3/me returned
401. Consistent with the 2-hour idle timeout, and decisive against the
30-day reading.
refresh-session is not special
POST /api/v3/authentication/refresh-session is what the "Sitzung verlängern"
button calls, but it carries @JwtAuthentication() like every other route, so
the extension is a side effect of the guard — the same side effect a plain
GET /api/v3/me produces. Its handler only calls getJwtTtlFromWhitelist and
returns { expiresInSeconds }.
That makes it useful as a measuring instrument rather than a necessity:
it is the only way to read how much idle budget is left. npm run probe
reports it. It is also the only non-GET call anywhere in this repository, and
it lives in that diagnostic script rather than in the server.
There is no longer window available
config/default.schema.json documents JWT_EXTENDED_TIMEOUT_SECONDS
(2629746 s ≈ 1 month), reachable in the legacy stack via a privateDevice
login flag. On the current NestJS server that is vestigial:
privateDevice has no references in apps/server/src, and
generateJwtAndAddToWhitelist calls addToWhitelist(accountId, jti) with no
TTL override, so every token gets jwtTimeoutSeconds. A "remember me" login
will not buy a longer idle window.
How this server stays alive
src/keepalive.ts pings GET /api/v3/me every 30 minutes (configurable via
KEEPALIVE_INTERVAL_MS; 0 disables it). Started by both entry points,
independent of whether any MCP client is connected — the token expires on wall
time, not on usage.
Thirty minutes against a 7200 s budget tolerates three consecutive failures
before the session is at risk. A transient failure retries in 5 minutes; a
401 stops the keepalive permanently and logs what to do, because a lapsed
whitelist entry cannot be revived by retrying — only by pasting a new token.
Operational consequence worth knowing: if the container is down for more than two hours — a long power cut, a Pi left off overnight — the token is dead when it comes back, and restarting will not fix it. The startup log says so immediately:
[schulcloud-mcp] keepalive: token rejected (401). The session has expired …
With the keepalive running, a token survives up to its 30-day hard expiry, at which point it must be replaced by hand regardless.
Why not username + password
The instance's login redirects to Keycloak (realm TIS) with a redirect_uri
pointing back at Schulcloud's own server, so the authorization-code exchange
happens server-side with a client secret only Schulcloud holds. A third party
cannot replicate that flow. POST /api/v3/authentication/local exists but is
for accounts with local credentials, which federated school accounts do not
have.
Given a keepalive that holds a token to its 30-day ceiling, the pasted-JWT approach is still the right trade: one manual step a month against re-implementing an OAuth client we cannot hold the secret for. If this ever needs to be unattended, the honest options are a service account issued by the school's IDM, or a headless browser login — not a reimplementation of the Keycloak exchange.
Protecting this server's own endpoint
Distinct from the above, and just as important. The MCP endpoint is reachable
from the public internet by construction: Claude's connectors call it from
Anthropic's cloud, not from your machine. It is protected by MCP_AUTH_TOKEN,
a shared secret checked in constant time on every /mcp request
(src/http/auth.ts), accepted as either Authorization: Bearer … or
X-Api-Key. /healthz is deliberately open and reveals nothing.
Generate one with openssl rand -hex 32. If it is unset the server logs a loud
warning and serves unauthenticated — only acceptable bound to localhost.
Rotating it: change MCP_AUTH_TOKEN in .env, restart the container, update
the connector in Claude. Nothing else stores it.
Blast radius
Every path in this server is a GET, including the api_get escape hatch,
which rejects anything not starting with /api/ and anything carrying a scheme
or host. Someone who obtained both the endpoint URL and MCP_AUTH_TOKEN could
read this account's Schulcloud data; they could not post, submit, delete, or
otherwise act as the user. Keep it that way — adding a single write tool would
change that property entirely.