Zokor documentation
HTTP APIs
Build HTTP APIs with Zokor routing, middleware, authentication, validation, uploads, rate limits, caching, pagination, and content negotiation.
Authentication#
Three helpers. Each answers "who is this?" and leaves the policy to you. There are no closures, so keys live in your state and a short before hook passes them in:
gc struct App { jwt: zokor.JwtAuth, keys: zokor.ApiKeys }
struct Claims { sub: str, role: str, exp: int }
fn require_user(c: zokor.Ctx[App]) -> opt[http.Response] {
let r: result[Claims, http.Response] = zokor.bearer_claims(c, c.state.jwt);
guard let claims = r else let deny = err_of(r) { return some(deny); }
c.set_local("user_id", claims.sub);
return none;
}JWTs, HS256 through dolphlabs/jwt:
- Configuration.
jwt_auth_from(cfg)readsJWT_SECRETS(comma-separated, newest first, 32+ bytes each),
JWT_AUDIENCE,JWT_ISSUERandJWT_LEEWAY(seconds of clock skew, default 30). - Checks.
bearer_claimschecks the signature, algorithm and duplicateclaims, then
exp(required),nbf, andaudandisswhen configured. Only then does it decode the payload into your own claims type. - Failures. Every failure gets the same
401withWWW-Authenticate: Bearer error="invalid_token".token_problem(token, auth, now)gives the reason for your logs. - Signing and rotation.
sign_token(claims, auth)signs with thenewest secret. A token signed with an older listed secret still verifies, so rotation logs nobody out.
Basic auth:
basic_credentials(c)returns the user and password, keeping a coloninside the password.
basic_matches(creds, user, pass)compares SHA-256 digests in constanttime, so neither content nor length leaks through timing.
basic_denied(c, realm)asks the browser to log in.
API keys:
API_KEYS=billing:key1,search:key2, 24+ bytes each, is read byapi_keys_from(cfg).api_key_client(c, keys)readsX-Api-Keyand returns the client'sname, so a request can be traced to who sent it.
- Only SHA-256 digests are kept in memory. Every key is compared, in
constant time.
Operator injection (MongoDB)#
MongoDB reads a key starting with $ as an operator. A login handler that passes the body to a query, with a body like {"user":"ada","password":{"$ne":""}}, matches any password. The guard works like express-mongo-sanitize and is opt-in:
r.mongo_sanitize(zokor.SanitizeMode.REJECT); // or zokor.sanitize_from(cfg): MONGO_SANITIZE- What it checks. Every key of a JSON body, at any depth and inside
arrays, and every query-parameter name, decoded first so
%24whereis$where. A key starting with$or containing a.is refused. REJECTanswers400 bad_requestnaming the key, for examplebody key 'password.$ne'. Nobeforehook and no handler runs.STRIPremoves the offending keys and carries on.- What it leaves alone. Values are never touched, so
"$5 off"isfine. A body that isn't JSON, or isn't valid JSON, is left for the handler to refuse.
- Cost. With the guard on, a JSON body is parsed once more.
SQL has no equivalent on purpose. A blacklist rejects O'Brien and still misses real attacks. The defence is parameterised queries, which the Postgres helpers use exclusively.
Cookies#
let theme = c.cookie("theme"); // "" when not sent
let ck = zokor.new_cookie("theme", "dark"); // HttpOnly, Secure, SameSite=Lax, Path=/
ck.max_age = 86400 * 30;
return zokor.set_cookie(resp, ck) ?? resp; // err names what is wrong- Safe defaults. A new cookie is
HttpOnly,Secure,SameSite=Laxand
Path=/, with noDomain, so it's host-only. - Checked before it's sent.
set_cookierefuses:- a value with a space, quote, comma,
;, backslash or control byte,which could add attributes of its own
- a bad name
- a path or domain carrying
; SameSite=NonewithoutSecure, which browsers drop- a
__Host-or__Secure-cookie that breaks its prefix's rules - more than 4,096 bytes
- a value with a space, quote, comma,
- Reading.
c.cookie(name)andc.cookies()skip malformed pairs theway browsers do, and the first of two same-named cookies wins.
- Deleting.
clear_cookie(resp, name, path, domain)removes a cookie(
Max-Age=0, and anExpiresin 1970 for clients that read only that). - Lifetime.
max_age(seconds;-1, the default, is a sessioncookie) is what every browser uses. Set
expirestoo, an instant fromtimez, for HTTP clients that only readExpires; when both are sentMax-Agewins:ck.max_age = 3600; ck.expires = some(timez.now().add(timez.HOUR)); // Expires=<HTTP date>
Signed cookies carry an HMAC-SHA256 over their name and value, so a client can read them but not change them, or move one to another name:
let keys = zokor.cookie_keys_from(cfg); // COOKIE_KEYS=newest,older
let ck = zokor.signed_cookie("uid", "42", keys) ?? zokor.new_cookie("uid", "");
let uid = zokor.signed_value(c, "uid", keys) ?? ""; // none when forgedThe first key signs and every listed key verifies. To rotate, put the new key first and keep the old one until the cookies it signed have expired; nobody is logged out. Keys must be at least 32 bytes. Signatures are compared in constant time. Signed is not encrypted: never put a secret in a cookie.
Conditional requests#
A client that already holds the current version gets 304 Not Modified and no body; a write made against a version that is no longer current gets 412 instead of overwriting someone else's change (RFC 9110 §13):
fn get_doc(c: zokor.Ctx[App]) -> http.Response {
let doc = load(c); // has .version, .updated_at
let tag = zokor.etag_of(to_bytes(to_str(doc.version)));
if let stop = zokor.preconditions(c, tag, some(doc.updated_at)) {
return stop; // 304, or 412 on a write
}
let resp = zokor.ok_json(json.encode(doc));
return zokor.with_validators(resp, tag, some(doc.updated_at));
}- One call for reads and writes.
preconditionsevaluatesIf-Match,If-Unmodified-Since,If-None-MatchandIf-Modified-Sincein the order RFC 9110 §13.2.2 sets. A PUT that sends back the ETag it read inIf-Matchupdates only if nobody changed the resource since: optimistic concurrency, no lock. Pass""as the tag for a resource that does not exist yet, andIf-None-Match: *means "create, never replace". - Comparisons as the RFC has them.
If-Matchcompares strongly, so aW/tag never matches;If-None-Matchcompares weakly. Tag lists,*, and all three HTTP date forms are read; a date that is not a valid HTTP date is ignored, and a malformed tag never matches. r.etags(), opt in: every200answer to a GET or HEAD gets anETag hashed from its body, and a matching
If-None-Matchgets a304that keeps the other headers (Cache-Control,Vary, …). A response with its own ETag is left alone. It hashes every such body, measured in process (bench/conditional): about 1.5 µs a request at 128 bytes, 2.7 µs at 1 KB and 20 µs at 16 KB. With nothing turned on, the request path costs what it did. For a large body, a version you already have (preconditions) is far cheaper than hashing the bytes.
Security headers#
On by default, on every response: dynamic, static, errors, and the 500 after a panic.
content-security-policy: default-src 'none'; frame-ancestors 'none'
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: no-referrer- CSP. An API answers JSON, so its CSP allows nothing: a response
opened in a browser runs no script and can't be framed.
- HSTS. Browsers ignore HSTS on plain HTTP, so it's harmless behind a
proxy that ends TLS, and right when the proxy doesn't.
- COOP and CORP are opt-in:
same-originCORP breaks legitimatecross-origin embeds.
- Handlers win. A handler that sets one of these headers with
with_headerkeeps its own value.
| Setting | Values |
|---|---|
SECURITY_HEADERS | false turns them all off |
CONTENT_SECURITY_POLICY | a policy, or off |
HSTS_MAX_AGE | seconds; 0 is off |
HSTS_INCLUDE_SUBDOMAINS, HSTS_PRELOAD | true / false |
FRAME_OPTIONS | DENY, SAMEORIGIN, off |
REFERRER_POLICY | any standard policy, or off |
CROSS_ORIGIN_OPENER_POLICY, CROSS_ORIGIN_RESOURCE_POLICY | the standard values |
r.security_headers(zokor.security_from(cfg)) applies the settings, and zokor.no_security_headers() turns them off. Bad values are refused with the fix: an unknown frame option or referrer policy, preload without a year and subdomains, or a CSP containing a line break. Static routes are pre-rendered with the headers, so they cost nothing per request there. On a dynamic response, the five lines add about 10 allocations.
CORS#
Off by default. One call turns it on:
r.cors(zokor.cors_from(cfg)); // CORS_ORIGINS=https://app.example.com,https://*.example.orgor r.cors(zokor.cors_policy(["https://app.example.com"])) from code.
| Setting | Meaning | Default |
|---|---|---|
CORS_ORIGINS | exact origins, https://*.example.com for subdomains, or * alone | none (off) |
CORS_METHODS | allowed methods | GET, HEAD, POST, PUT, PATCH, DELETE |
CORS_HEADERS | request headers a page may send | Content-Type, Authorization, X-Request-Id |
CORS_EXPOSE | response headers a page may read | X-Request-Id |
CORS_CREDENTIALS | allow cookies and Authorization | false |
CORS_MAX_AGE | seconds a browser may cache a preflight | 600 |
The router applies it, so it isn't one more before:
- Preflights. A preflight to a path that exists gets a
204with theallowed methods and headers before routing. Neither an auth hook nor a route's own
OPTIONShandler can turn it away, and browsers send preflights without credentials. - Every response. Any answer to an allowed origin carries its headers:
the 404, the 422, a
before's 401 and the 500 after a panic. Without that, the page sees a CORS error instead of the real one. Vary: Origin. It's sent whenever the answer depends on the origin,so a shared cache never hands one origin's answer to another.
- Other origins. An origin that isn't allowed gets no CORS headers, and
the browser withholds the response from the page.
A policy is checked before use, and every problem names the fix:
- an origin with a path or trailing slash, which an
Originheader never has - a missing scheme
- upper case
- a wildcard anywhere but a leading
*. *together with credentials, which browsers refuse
https://*.example.com matches api.example.com and a.b.example.com, but never example.com or evilexample.com. CORS protects users' browsers, not your server: it is no substitute for authentication.
Health and readiness#
r.health(); // GET /healthz and GET /readyz
r.ready_check("db", db_ready); // what /readyz waits on
fn db_ready(s: App) -> result[int, str] {
let q = s.db.query("select 1");
guard let _rows = q else let e = err_of(q) {
return err(to_str(e));
}
return ok(0);
}/healthz is liveness: 200 {"status":"ok"} whenever the process is serving, and it never touches a dependency, so a database outage cannot make the orchestrator restart every replica. /readyz runs every check at once and answers 200 {"status":"ok","checks":{"db":"ok"}}, or 503 with "failing" or "timeout" against each check that did not pass. It also answers 503 shutting_down once SIGTERM arrives, so a load balancer stops sending traffic before the drain.
- Bounded. A probe waits at most 0.9s (
r.ready_timeout(ns)), insideKubernetes' default 1s probe timeout.
- Isolated. A check runs in its own task, so a panic fails that check,
not the probe. A check still running from an earlier probe is joined, not started again, so a hung database costs one task, not one per probe.
- No leaks. The body names checks, never errors: a driver's message
carries hosts and user names. The reason goes to the server's log as a
WARN [Health]line, once each time a check starts failing, changes reason or recovers. - Out of the way. The router answers these paths only when no route
claims them, so your own
/healthzwins. Nobeforeruns on them, since an auth hook would fail the probes.r.health_at(live, ready)mounts them elsewhere.
Static files#
let mr = zokor.files_at("/assets", "./public"); // err if not a directory
guard let assets = mr else let e = err_of(mr) { println(e); exit(1); }
assets.max_age = 31536000; // hashed names: a year
assets.immutable = true;
r.files(assets);A GET or HEAD under the prefix that no route claims is a file from the directory:
- Traversal-safe. An encoded separator (
%2f,%5c), a..,.ordot-file segment (
.env,.git), a backslash or a NUL is a 404, and the file's real path, every symlink resolved, must be inside the directory's real path or it is a 404 too: a link cannot lead out. - Index files. A directory answers
index.html(mount.index). - Validators. ETag from size and modification time, Last-Modified,
and
304/412throughpreconditions. - Ranges. One
Range: bytes=range is a206withContent-Range(
If-Rangehonoured); one past the end a416; several ranges get the whole file, as RFC 9110 allows. - Headers. Content-Type by extension (unknown types are
application/octet-stream, whichnosniffkeeps from being rendered),Cache-Controlfrommax_age(no-cacheby default: revalidate every time, cheap with the validators) andAccept-Ranges.
Files are read into memory, up to max_size (64 MB), and the read blocks a worker: serve an app's own assets this way, and put a CDN in front of large or busy ones.
Content negotiation and redirects#
Compile the representations a handler can serve at startup:
let made = zokor.new_negotiator(["application/json", "text/html"], zokor.accept_limits());
guard let formats = made else let e = err_of(made) { println(e); exit(1); }
// Keep formats in the router's state. In the handler:
let picked = zokor.negotiate(c, c.state.formats);
guard let media = picked else let bad = err_of(picked) { return bad; }
// Render the selected representation, then return it with media as Content-Type.negotiate combines repeated Accept lines, preserving quoted parameters. It honors q weights, wildcards and parameter specificity: a specific q=0 excludes that representation even when a wildcard permits others. Equal preferences use specificity, client range order, then startup offer order. An absent header selects the first offer; an empty header or no acceptable offer produces the registered not_acceptable 406. Malformed or over-limit headers produce bad_request 400 with an Accept field. All these responses carry Vary: Accept, alongside compression and CORS fields. Negotiator.choose provides selection without a request context.
accept_limits() defaults to 8 KB of combined header values, 64 list members, 32 parameters per range (including q), 32 offers and 1 KB per offer. Set these before constructing the negotiator. Configure once and treat its compiled vectors as immutable.
let response = zokor.redirect_to("/orders/123"); // checked 303, POST/redirect/GET
let rules = zokor.redirect_rules(); // relative references, 8 KB limit
rules.allow_external = true;
let moved = zokor.redirect_checked(307, "https://app.example.com/orders", rules);Checked helpers return a result; they validate 301/302/303/307/308 and preserve the destination with an empty body. Relative paths, queries and fragments are accepted. External HTTP(S) URLs require explicit opt-in; network-path references (//host), credentials, raw whitespace, controls, backslashes and malformed percent escapes are refused. Other bytes must be percent-encoded. Errors name the field without echoing the destination. For variable external destinations, the application chooses its permitted hosts. The existing redirect(status, location) remains the raw constructor for destinations the application already controls.
Compression#
r.compress(zokor.compression()); // min_size 1 KB, level 1, text-like typesA response is gzipped when the client's Accept-Encoding allows it (q values honoured: gzip;q=0 refuses it), it is a 2xx other than 204/206 to a method other than HEAD, its body is at least min_size, its type is text, JSON, JavaScript, XML, SVG or form data (or ends in +json/+xml), and it has no Content-Encoding of its own. Every response that could have been gzipped carries Vary: Accept-Encoding; a strong ETag becomes weak. Level 1 is the default: on API JSON it keeps nearly all of level 5's saving (14 KB: 36 µs to 10% of the size, against 74 µs) for half the CPU. Static routes are sent as rendered.
BREACH. Over TLS, compressing a response that holds a secret next to input an attacker controls lets them recover the secret from sizes. Keep pages that mix the two (a CSRF token and reflected search terms) on a router without compression.
Idempotency keys#
A client that retries a POST after a timeout must not charge twice. With Idempotency-Key (the IETF draft), it gets the first answer instead:
let store = zokor.idempotency_redis(redis_pool); // or idempotency_in_memory()
store.scope_local = "user_id"; // set by your auth `before`
r.idempotency(store);- Runs once. A POST or PATCH with the header runs; its answer is kept
for 24 hours (
store.ttl). The same request again gets that answer, withIdempotent-Replayed: true, and the handler does not run. - Refuses misuse. The key again while the first is still running:
409. The key with a different method, path or body:422. A key that is not 1–255 printable characters:400.store.required = truemakes the header mandatory on POST and PATCH. - Scoped. Keys are per method, route and caller (
scope_localnamesthe local your authentication sets), so one user cannot replay another's answer.
- Only final answers. A 5xx, 408 or 429 is not kept: the next retry
runs again. Neither is a body over
store.max_body(1 MB). A handler that panics leaves its key "running" forstore.lock_ttl(30 s). - Shared. The Redis store claims a key with
SET NX PX, so instancesbehind a balancer agree; if Redis cannot be reached the request is refused with 503 rather than risk running twice. The in-memory store is for a single instance.
Pagination#
Offset pages (?limit=20&offset=40) and keyset pages (?limit=20&cursor=…) answer in the same envelope, so a client reads every list the same way:
fn list_orders(c: zokor.Ctx[App]) -> http.Response {
let pr = zokor.page_of(c, 20, 100); // default 20, at most 100
guard let p = pr else let bad = err_of(pr) { return bad; } // 422, named
let after = zokor.cursor_parts(p); // [] on the first page
// WHERE (created, id) < ($1, $2) ORDER BY created DESC, id DESC LIMIT p.fetch()
let rows = load_orders(c, after, p.fetch());
return zokor.cursor_page(c, p, rows, order_key);
}
fn order_key(o: Order) -> [str] { return [o.created, to_str(o.id)]; }{"data":[...],"page":{"limit":20,"cursor":"WyIyMDI2...","next":"WyIyMDI2...","total":null}}- One row past the page.
p.fetch()islimit + 1: an extra rowmeans there is a next page, without a
COUNT(*).offset_pagetakes an optional total when the handler has one. - Links. A
Linkheader (RFC 8288) carriesrel="next"(andrel="prev"for offset pages), keeping the request's other query parameters. - Checked.
limitmust be 1 to the maximum;offset0 to 1,000,000(deeper: use a cursor); not both
offsetandcursor; a cursor this API did not issue is refused. Each problem is a named field in a 422. - Keyset is the one to use on big tables: the next page is an index
range, and rows inserted between requests are neither skipped nor repeated. A cursor is opaque base64url of the last row's sort key; a client can forge one, but only to read another position of a list it may read anyway, so authorise the list, and never put a secret in a key.
JSON#
slang already does the part Go does badly: you decode into a struct, a missing field is an error rather than a silent zero, and the message names the field. No tags, no interface{}, no omitempty guessing. zokor adds the four things that are still missing.
A DTO, decoded and reported. The wire is camelCase, your struct is snake_case, and there are no struct tags — so rewrite the keys:
gc struct CreateOrg { name: str, plan: str, seats: i32, website: opt[str] }
let r: result[CreateOrg, str] = json.decode(zokor.snake_keys(c.body_str()));
guard let dto = r else let e = err_of(r) {
return zokor.decode_failed(c.state.errors, e, c.request_id);
}decode_failed turns slang's own message into the standard envelope with the field named, so a client gets the same shape for a bad type as for a bad value:
{"error":{"code":"validation_failed","status":422,
"fields":[{"field":"seats","reason":"expected a number, got a string"}]}}
{"error":{"code":"validation_failed","status":422,
"fields":[{"field":"plan","reason":"is required"}]}}Values, checked all at once. Decoding says a field is a string; it does not say it is an email between 3 and 40 characters. A checker collects every problem, because a client fixing one field per request is a client making five requests:
let v = zokor.checker();
let name = v.req_str(body, "name", 1, 80);
let email = v.req_email(body, "email");
let role = v.req_one_of(body, "role", ["admin", "member"]);
let age = v.opt_int(body, "age", 0, 150, 0);
if v.failed() {
return zokor.respond_fields(c.state.errors, "validation_failed",
v.fields, c.request_id);
}req_str, req_int, req_bool, req_email, req_url, req_uuid, req_one_of, req_strs, req_obj, opt_str, opt_int, plus note for a rule of your own and under("address", inner) to report a nested check as address.city.
A shape nobody declared — a webhook, a passthrough, somebody else's field. A missing key gives Null rather than an error, so a chain never has to be guarded at every step:
guard let body = c.json_body() else { ... }
body.get("type").str_or("unknown")
body.path("data.customer.address.city").str_or("nowhere")
body.path("data.items.0.sku").str_or("none")
body.path("data.items").size()
body.has_key("note") // present, even if null: PATCH semanticsBuilding a response, instead of concatenating strings and getting the quoting wrong:
zokor.jobj()
.set_str("id", id)
.set_int("seats", n)
.set_opt_str("website", dto.website) // absent when none
.set("tags", zokor.jarr().add_str("a").add_str("b"))
.render()Numbers keep the text they arrived as, so a 64-bit id survives a round trip; \uXXXX escapes and surrogate pairs decode; and the parser refuses what JSON refuses — a leading zero, a lone surrogate, an unescaped control character, nesting past 64 deep.
Uploads#
multipart/form-data is a format where being lax is a vulnerability: the client picks the delimiter, names the parts and supplies the filenames. A handler says what it accepts; anything else comes back as a registered failure and never reaches it.
fn upload_avatar(c: zokor.Ctx[App]) -> http.Response {
let rules = zokor.with_max_file_bytes(
zokor.uploads_allowing(["image/png", "image/jpeg"]), 1048576);
let r = c.upload(rules); // POST/PUT/PATCH only
guard let form = r else let e = err_of(r) {
return zokor.respond_with(c.state.errors, e.code, e.detail, c.request_id);
}
guard let avatar = zokor.file(form, "avatar") else {
return zokor.respond(c.state.errors, "validation_failed", c.request_id);
}
zokor.value(form, "title"); // the non-file fields
zokor.files_for(form, "docs"); // <input multiple>
avatar.filename; // safe to use as one path segment
avatar.raw_filename; // what the client actually sent
return zokor.ok_json("{\"bytes\":" + to_str(len(avatar.data)) + "}");
}Filenames are made safe: everything before the last / or \ is dropped, control characters go, a leading dot goes, and a name that is empty after that becomes upload. ../../etc/passwd arrives as passwd. The name the client sent is kept as raw_filename for logs.
Limits are the caller's, checked while the body is walked: parts, files, bytes per file, bytes per field, total bytes, header size, filename length. Over a size limit is a 413; a body that is not well-formed multipart is a 400; a type or extension that is not allowed is a 415.
Refused rather than guessed at: a Content-Type that is not multipart or has no boundary, a boundary that is empty or over 70 characters, a body that stops before its closing delimiter, a part with no Content-Disposition or no name, unterminated or oversized part headers, and a Content-Transfer-Encoding the web does not use. A file sent with no Content-Type is refused unless you turn require_content_type off.
A body arrives whole, so this does not stream: set max_total_bytes for what a service can afford to hold.
Rate limits and bearer tokens#
Both are values in your state, shared by every request and safe to use from concurrent handlers (each holds a mutex).
gc struct App {
errors: zokor.Registry,
limiter: zokor.RateLimiter,
tokens: zokor.TokenStore,
}
// 100 requests per client per minute; tokens last a day. Durations
// are int nanoseconds, written out: `60 * 1000000000` overflows in
// slang today (a product of two literals is computed in 32 bits).
let app = App {
errors: zokor.new_registry(),
limiter: zokor.new_rate_limiter(100, 60000000000),
tokens: zokor.new_token_store(86400000000000)
};
fn limit(c: zokor.Ctx[App]) -> opt[http.Response] {
return zokor.rate_limit(c, c.state.limiter, c.header("x-api-key"));
}
fn auth(c: zokor.Ctx[App]) -> opt[http.Response] {
guard let user = zokor.bearer_subject(c, c.state.tokens) else {
return some(zokor.respond(c.state.errors, "unauthorized", c.request_id));
}
c.set_local("user", user);
return none;
}- Refusals.
rate_limitanswers a refused request with theregistered
rate_limitedcode (429, through your renderer if you set one) andRetry-Afterin whole seconds. Refused requests don't count against the window. - Headers on every answer. Allowed or refused, a response carries
RateLimit-Limit,RateLimit-RemainingandRateLimit-Reset(seconds until the next request frees up), so well-behaved clients can pace themselves. - Keys. The key is yours to choose: an API key, a user, an address.
An empty key shares one "anonymous" budget.
rate_limit_client(c, l)keys on the client's address, resolved throughTRUSTED_PROXIES, so a client can't escape its limit or spend someone else's by sending a header. - Concurrency. The limiter is split into 16 shards, each with its own
lock, so tasks checking different clients don't wait on each other. With 8 tasks: 2.0 µs a check, against 17.3 µs with a single lock.
- Testing.
take_at(key, now)andcheck_at(key, now)take theclock as a parameter, so tests need no sleeps.
A before hook that lets a request through can still add headers to its answer with c.add_header(name, value): the limiter uses it, and so can a hook that refreshes a cookie.
TokenStore.issue(subject) returns a 64-hex-character token from crypto.rand; lookup(token) gives the subject back (expired tokens are dropped), revoke(token) logs one session out and revoke_subject(subject) all of them. Tokens live in memory: they do not survive a restart and are not shared between instances.