Sessions, Refresh Tokens & Audit
Authentication in Blong issues access tokens (short-lived JWTs) that the gateway verifies without touching the database — that is the fast path for every request. On top of it, Blong maintains database-backed sessions that add revocation, inactivity enforcement, token renewal, and an append-only audit trail. This document explains the model and the security decisions behind it.
Why both tokens and sessions?
| Concern | Access token (JWT) | Session (DB row) |
|---|---|---|
| What it proves | "This request is signed by a user" | "There is a live, non-revoked login for this user" |
| Checked on every request | ✅ yes (signature + expiry, no DB) | ❌ no (only on renewal / explicit verify) |
| Revocable immediately? | No — lives until exp | Yes — access.session.close / logout |
| Carries permissions | ✅ yes (per role-bit map + resolved actions) | No (re-resolved on renewal) |
Because access tokens are verified statelessly, closing a session does not immediately invalidate already-issued access tokens. Instead the session's renewal is refused: the access token keeps working until it expires, but it cannot be refreshed, so the client is effectively logged out within one access-token lifetime. This is the "closing a session leaves an active token for up to the refresh interval" behaviour.
Session lifecycle
A session is created on every successful login.token.create password grant and carries:
sessionId— the JWTsesclaim;tokenHash— SHA-256 hex of the current refresh token (rotated on every renewal — this is what makes a stolen, already-used refresh token useless);issuedAt/expiresAt— absolute refresh-token lifetime (fixed);lastActivityAt— the inactivity anchor (touched on renewal and on explicitaccess.session.verify);ipAddress,isRevoked,revokedAt,cookieHash(restore-cookie handle digest, see below).
The client_credentials grant is different: it issues long-lived app tokens and creates no DB
session — no refresh rotation, inactivity tracking or restore cookie applies to machine
credentials.
The lifecycle is a small state machine — a session is issued, renewed in place, and then ends either by revocation or by inactivity, after which cleanup deletes the row:
Renewal is the one transition with more than a state change, so it is worth drawing in full:
The rotation is what makes a stolen, already-used refresh token useless: the stored tokenHash
follows the newest token, so replaying an older one no longer matches. Logout sets isRevoked and
revokedAt and clears the restore cookie; cleanup then deletes the stale, revoked and expired rows.
Inactivity timeout & deletion
- Inactivity timeout (
login.expire.inactivity, default 30 min): a session whoselastActivityAtis older than the timeout is refused renewal and is reportedinactivebyaccess.session.verify. - Delete interval (
login.expire.deleteAfter, default 24 h): stale rows — revoked more than the interval ago, inactive for longer than the interval, or pastexpiresAt— are purged byaccess.session.cleanup. Note that the interval is currently config that does not reach the handler: every caller passes an empty argument set andaccess.session.cleanupthen uses its own 24-hour default, so changinglogin.expire.deleteAfterhas no effect.
Cleanup is a plain knex handler (dialect-neutral — cut-offs are computed in JS, no stored procedures or SQL date functions). It is invoked lazily from the login, refresh, restore and revoke flows; calling it on a schedule is an option rather than something the framework does for you.
Closing a session
access.session.close is the logout/revoke primitive, and what it takes to call it depends on how
the call arrives. When no id is passed it defaults to the JWT ses claim ($meta.auth.sessionId),
so an empty call logs the caller's own session out — but only through login.token.revoke,
which is declared on the route with the authorization check skipped. Called directly as
access.session.close over RPC, the gateway's access check runs first, so it needs the
access.session.close action like any other method; the adapter's own-session shortcut is reached
after that check, not instead of it. Closing another session (an arbitrary id) always needs the
action, and is refused with access.session.closeForbidden (403) otherwise. Revocation clears the
restore cookie.
Token renewal
login.token.refresh redeems the CBC-encrypted refresh token:
- decrypt + check the refresh token's own expiry;
access.session.verify— the DB session must be live (not revoked / expired / inactive) and the user still eligible (active +accessLoginaction — a deactivated user or one who lost the login capability cannot renew), andlastActivityAtis touched (renewing updates the inactivity);- the refresh-token hash is checked against the session — a mismatch means the token was already rotated (stolen/replayed), so the session is revoked;
access.permission.listre-resolves the current permission set — role / capability / action changes take effect on the next renewal without a re-login;- fresh access token + rotated refresh token, and the session's
tokenHashis updated.
The front-end (via the MLE codec — a non-UI, SDK-usable component) refreshes automatically a few seconds before expiry. An unexpected 401 is surfaced as-is — there is no forced retry, because the pre-send renewal already ran, so a 401 means the session is genuinely unusable (e.g. revoked server-side). If renewal is refused the tokens are dropped and the caller receives a 401, which the UI turns into a login popup.
Standard method for critical operations
Normal operations rely on the stateless JWT fast path. Operations that need a stronger guarantee —
e.g. DB writes, sensitive transfers — should verify the session at the start of the handler.
access.session.verify throws an auth-classified (401) access.session.* error when the
session is not live, so the guard is a single line:
const {userId} = await handler.accessSessionVerify({}, $meta); // throws on invalid
access.session.verify (session id defaults to $meta.auth.sessionId, the JWT ses claim) checks:
exists → not revoked → not past expiresAt → not inactive → user active → holds the accessLogin
action. It throws access.session.notFound / access.session.revoked / access.session.expired /
access.session.inactive / access.session.userInactive / access.session.loginNotAllowed; the
failing reason is on error.params.reason. Pass touch: true to also reset the inactivity timer.
The same call also closes the two gaps the stateless JWT leaves open:
- the action is re-validated live —
action(default$meta.method) is checked againstcore.path(access.effectiveAction) rather than thepermap baked into the token, so an action revoked since the token was issued is refused withacl.notPermitted(reason: 'actionNotPermitted'); - a guarded record is required — when the action belongs to an entity that opts into the
record-level ACL (see the
blong-accessREADME and the Record-level ACL concept),get/edit/removeneedrecord: {entity?, recordId}andaddneedsscope: {entity?, recordId}. A caller that omits the key fails withacl.notPermitted(reason: 'recordRequired'/'scopeRequired') instead of passing silently, and an out-of-scope or denied record is refused withacl.denied/acl.scopeDenied.
await handler.accessSessionVerify(
{action: 'party.person.edit', record: {entity: 'party.person', recordId}},
$meta,
);
Login eligibility (who may hold a session)
A user may establish or renew a session only while they are still allowed to log in. Two gates
are enforced at the session-lifecycle operations — login.token.create, login.token.refresh and
login.token.restore (the points that mint or extend tokens):
| Gate | Mechanism | Admin action to disable a login |
|---|---|---|
| Per-user | user.isActive must be true | set user.isActive = false |
| Per-role | effective actions must include the accessLogin action (always on) | remove the accessLogin capability/action from the role |
Both are enforced at create (login) and, critically, at refresh/restore: renewal is the
operation that would otherwise keep a disabled user's session alive, so refusing it makes the
disable effective within one access-token lifetime. Failed gates throw login.userInactive /
login.loginNotAllowed (401) and are recorded in the audit.
The per-role gate reuses the normal RBAC graph — seed the accessLogin action and a capability for
it, then grant that capability to the roles that may log in:
# meta/dbTest/accessAuthorizationMerge.yaml (test) or meta/db/*Merge.yaml (prod)
role:
Admin: …,loginCapability
capability:
loginCapability: accessLogin
The gate is always on — every role that may log in must grant accessLogin (add the
loginCapability capability to the role). access.session.verify (the critical-operation gate)
checks session liveness and login eligibility — a deactivated user
(access.session.userInactive) or one who lost the accessLogin action
(access.session.loginNotAllowed) is refused at the critical operation itself, not only at the next
renewal.
blong-login works without blong-access (configurable methods)
blong-login does not hard-depend on blong-access: every access method it calls is configurable via
login.methods.* (a wire name) and defaults to the blong-access handler. A lightweight suite
without blong-access can override a method with its own handler, or set one to false to disable
that functionality — e.g. sessionCreate/sessionVerify/… = false for stateless tokens,
auditRecord = false to skip auditing, permissionList = false to issue tokens without RBAC
permissions. A flow that needs a method which is disabled fails with a clear
login.configurationError.
Audit
Every access-control decision is recorded along with the access check at the gateway — this
is the single choke point that every operation controlled through RBAC passes. When gateway.audit
is configured (e.g. {handler: 'access.audit.record', exclude: [...]}), each request's allow/deny
is appended to access_audit with the actor, session, method, outcome, HTTP status and IP.
Access-table DML (access.user.add, access.role.edit, …) additionally carries a sanitised
detail (entity + id/name keys only — never credentials or hashes). Login success/failure is recorded
by the login flow itself.
An operator reads the result two ways, and neither of them is a SQL prompt. The
access.audit.browse page lists occurredAt, actionName, actorId, sessionId, statusCode,
userId, credentialType, ipAddress and isSuccess, browse-only and with no toolbar — an audit
log an operator can edit is not an audit log. access.audit.find is the method behind it, so the
same rows are reachable from an orchestrator or a test (the session flow asserts a login entry by
actionName and sessionId) and it is granted to Admin in the test authorization seed.
Audit is best-effort and non-blocking — an audit failure never fails a request. Operations that
record their own audit trail (e.g. payments) or that must not generate audit noise (integration-test
probes) opt out by declaring audit: false on the route, or by listing a methodId pattern in
gateway.audit.exclude.
Restore cookie (skip login on reload)
To avoid re-login on page reload, login sets an opaque restore cookie:
HttpOnly— not readable by JavaScript (defends against XSS token theft);Secure— HTTPS only;SameSite=Lax— CSRF mitigation;Path-scoped to the restore URL only (/rpc/login/token/restore) — the cookie is never sent to other endpoints;- value = a random opaque handle; only its SHA-256 digest is stored on the session row
(
cookieHash), so a leaked database cannot be replayed as cookies; - TTL from
login.expire.cookie; rotated on every use (one-time semantics).
On reload the UI calls login.token.restore, which exchanges the cookie for a live session (same
validity checks as verify — including login eligibility: a deactivated user, or one who lost
the accessLogin action, cannot resume the session via the cookie), re-resolves permissions and
mints fresh tokens — the login screen is skipped. On logout the cookie is cleared and the session
revoked, so a reload shows the login screen.
Why not store the JWT itself in the cookie? A bearer token in a cookie is CSRF-exposed (browsers attach cookies to cross-site requests). An opaque handle that only the same-origin restore endpoint can exchange keeps the bearer tokens out of the cookie jar entirely.
__Host-cookies cannot be combined with a sub-path scope, so the Path-scoping is the deliberate trade-off here.
Self-registration
access.registration.add creates an account from an email address and a password, and it is the one
entry point in the login flow that is deliberately reachable without a session: its route is
declared auth: 'login', so the endpoint answers but the call is not yet an authenticated
operation. It validates the address and a password of at least eight characters, hands off to
accessAccountAdd, and then creates the human record behind the account:
accessAccountAddchecks that the email is unused, creates theaccess.userresource, stores the PBKDF2 password (or a Google subject id, for the federated path), and gives the account theGuestrole.party.person.addcreates the person that the account belongs to — a cross-realm call, because an account belongs to the access realm and a person to the party realm.- A
hasProfiletriple incore.triplerecordsuser → person, which is what turns the account into a profile in the rest of the graph.
Google sign-in with no matching link and no matching address runs the same handler with
googleSubjectId instead of a password, so an operator sees one shape of account however it
arrived.
Two things about the outcome are worth stating plainly, because the usual expectation is the
opposite. A registered account is active immediately — there is no pending state, no email
verification and no approval queue; searching the realm finds no such concept. And the rights it has
are whatever the Guest role carries, today accessTestGuest and access.profile.get from the
authorization seed. Everything beyond that is an operator grant through access.user.edit, which is
also why the audit trail matters here: a registration is a write with no actor behind it, and the
access.audit.record entries for the account it produced are how it becomes attributable.