SafePass device session lifecycle v1
OPAQUE login issues a 15-minute access JWT and a 256-bit random refresh token. Refresh tokens use canonical, unpadded Base64URL on the wire. PostgreSQL stores only their SHA-256 hashes.
Each login starts a device-session family. A refresh request consumes the old token and inserts its successor in one database transaction. A second use of an already consumed token revokes every token in that family. The current refresh token expires after 30 days, and a family has a fixed 90-day maximum lifetime. Reauthentication with OPAQUE starts a new family.
The Flutter client stores the refresh token as a blob in the SQLCipher remote
account link. The database key is held by the platform secure-storage adapter.
After a local vault unlock, the client rotates the saved token and retains the
access JWT only in memory. It also rotates shortly before access expiry. Locking
the vault clears access and refresh token buffers from the client session; the
encrypted refresh token remains in the database for the next unlock. Explicit
logout revokes one family when the API is reachable. An authenticated
DELETE /v1/account/sessions revokes all refresh families for the account.
Session identifier and revocation
Every access JWT carries an sid claim: the refresh-token family it was issued
for, in canonical lowercase UUID form. Refresh rotation keeps the family, so the
sid stays the same for a device until it signs in again. The API checks on
every authenticated request that this family still belongs to the token's
account, is not revoked and has not passed its 90-day limit. Revoking a session,
replay detection, a password change, recovery-key account recovery and account
deletion therefore stop the affected access JWTs immediately instead of at their
15-minute expiry. Tokens issued before this check existed have no sid; they
are accepted only until they expire.
An authenticated GET /v1/account/sessions lists the caller's active families,
newest activity first: id, createdAt (first sign-in), lastActiveAt (latest
refresh rotation), expiresAt (expiry of the current refresh token) and
current (the family of the calling token). The server stores no device name,
user agent or IP address for a session. DELETE /v1/account/sessions/{id}
revokes one family of the caller and returns 404 for an unknown, foreign,
expired or already revoked ID, so it cannot be used to probe other accounts.
Account deletion
POST /v1/account/deletion takes the same two-minute, single-use ticket that a
password change obtains through /v1/account/password-change/reauth/start and
/finish, so deleting an account always needs a fresh proof of the current
password. One PostgreSQL transaction consumes the ticket and deletes the
account's encrypted revisions, sync cursor, recovery challenges, pending OPAQUE
login state and account row; refresh tokens, the recovery envelope and other
tickets are removed by foreign-key cascade. Copies of the vault stored on the
user's devices are not affected. See
account recovery v1 for the signed challenge protocol.
Refresh requests and logout use generic authentication failures, have the same per-IP rate limit as OPAQUE routes, and never log request bodies or token values. A lost response after successful rotation can leave a client holding a consumed token; the client must then reauthenticate after the server rejects the replay.