Skip to content
SafePass

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.

Documentation