Session Restoration#
Session restoration is the startup process by which Element Web/Desktop re-establishes a previously authenticated Matrix session without requiring the user to log in again. The entry point is loadSession() in Lifecycle.ts, which runs at application startup and attempts, in order:
- Resuming a guest session from URL fragment parameters
- Restoring a stored session from
localStorage/IndexedDB viarestoreSessionFromStorage() - Auto-registering as a guest (if enabled)
If restoration succeeds, doSetLoggedIn() dispatches Action.OnLoggedIn and starts the Matrix client. If it fails, the app either falls back to the welcome screen (for clean failures) or shows a SessionRestoreErrorDialog.
Key source files:
| File | Purpose |
|---|---|
apps/web/src/Lifecycle.ts | Orchestrates the full restore flow |
apps/web/src/utils/tokens/tokens.ts | Token encryption/decryption utilities |
apps/desktop/src/ipc.ts | Desktop IPC: pickle key get/create/destroy |
apps/desktop/src/store.ts | Safe storage backend (OS keychain) |
apps/web/src/components/views/dialogs/SessionRestoreErrorDialog.tsx | Error dialog shown on restore failure |
Credential Retrieval & Token Decryption#
getStoredSessionVars() assembles session state from localStorage (homeserver URL, user ID, device ID, guest flag) and IndexedDB (access/refresh tokens). getStoredToken() loads each token from IndexedDB first, and automatically migrates it from localStorage if found there.
A critical sanity check: if mx_has_access_token is true but no actual token is retrieved (IndexedDB blown away), abortLogin() is called, showing a StorageEvictedDialog .
Token Encryption#
Tokens are stored either as plain strings (no pickle key available) or as AESEncryptedSecretStoragePayload objects. At restore time, tryDecryptToken(pickleKey, token, tokenName) handles both cases:
- Plain string → returned as-is
- Encrypted → the pickle key is run through HKDF (SHA-256, 256-bit output) to derive an AES key , then
decryptAESSecretStorageItemdecrypts the payload - Encrypted, but no pickle key → throws
"Error decrypting secret {tokenName}: no pickle key found"— the most common error seen in session restore failures
At persist time, persistTokenInStorage() attempts AES encryption if a pickle key is present; if WebCrypto is unavailable, it falls back to storing the token unencrypted. Storage is IndexedDB-first with a localStorage fallback.
The storage keys in use:
| Key | Location | Purpose |
|---|---|---|
mx_access_token | IDB/localStorage | (Possibly encrypted) access token |
mx_refresh_token | IDB/localStorage | (Possibly encrypted) refresh token |
mx_has_access_token | localStorage | Sentinel: do we expect a token in IDB? |
mx_hs_url, mx_user_id, mx_device_id | localStorage | Session metadata |
Pickle Key & OS Keychain (Desktop)#
The pickle key is a per-device secret that serves two purposes:
- Derives the AES key used to encrypt access/refresh tokens in storage
- Keys (or passwords) the Rust crypto store (via
rustCryptoStoreKey/rustCryptoStorePasswordinMatrixClientPeg)
On desktop, pickle keys live in the OS keychain via Electron's safeStorage API — gnome_libsecret on Linux, DPAPI on Windows, Keychain on macOS. The key is identified by the composite string ${userId}|${deviceId}.
Desktop IPC Flow#
The renderer calls PlatformPeg.get()?.getPickleKey(userId, deviceId), which sends an IPC message to the main process. The main process handlers in apps/desktop/src/ipc.ts:
getPickleKey→ callsstore.getSecret(userId|deviceId); returnsnullon any error (so the app starts with the default key rather than crashing)createPickleKey→ generates 32 random bytes, stores viastore.setSecret(...), returns the keydestroyPickleKey→ callsstore.deleteSecret(...)on logout
loadOrCreatePickleKey() in Lifecycle.ts tries getPickleKey first and only calls createPickleKey if nothing is returned.
Safe Storage Backend#
SafeStorageWriter.get() in store.ts retrieves the base64-encoded ciphertext from the Electron store and decrypts it with safeStorage.decryptString(). If decryption throws (e.g. the OS keychain is locked), it catches the error, logs it, and returns undefined.
Known issue: Returning undefined is indistinguishable from "key not present." When the keychain is transiently unavailable, loadOrCreatePickleKey() calls createPickleKey, which overwrites the still-valid ciphertext — turning a transient failure into permanent session/crypto loss. PR #33986 (open as of mid-2026) proposes fixing this by introducing a typed SafeStorageDecryptionError to distinguish "absent" from "present-but-undecryptable," and blocking createPickleKey from overwriting an undecryptable secret.
Client Setup#
Once credentials are decrypted, doSetLoggedIn(credentials, clearStorageEnabled, isFreshLogin) drives the transition to logged-in state:
- Optionally clears storage (fresh login) and checks
StorageManager.checkConsistency()— if crypto store data is missing but local storage data exists, it callsabortLogin(). - Creates a
MatrixClientviacreateClientWithCreds()and assigns it toMatrixClientPeg. For OIDC sessions with a stored refresh token, aTokenRefresheris wired in . - Dispatches
Action.OnLoggedInsynchronously so the SDK context is ready for other modules. - Persists credentials back to storage (including encrypting tokens with the pickle key) .
- Calls
startMatrixClient(), which starts all SDK services — notifier,DMRoomMap,IntegrationManagers,DeviceListener,EventIndexPeg— and begins syncing.
The pickle key length determines the crypto store initialization mode :
- 43-char base64 string → decoded as a raw 256-bit
rustCryptoStoreKey - Anything else (legacy) → used as a
rustCryptoStorePassword
Error Handling#
SessionRestoreErrorDialog#
Any unhandled exception from loadSession() routes to handleLoadSessionFailure(), which shows SessionRestoreErrorDialog. The dialog presents:
- Send Logs (if a bug report endpoint is configured) — opens
BugReportDialogwith the error attached - Refresh (otherwise) — reloads the page
- Clear Storage & Sign Out (danger) — clears all storage and returns to the login screen
If the user chooses not to clear, handleLoadSessionFailure() calls loadSession() again (retry).
Special Error Cases#
| Error / Condition | Behavior |
|---|---|
AbortLoginAndRebuildStorage | Silent return to welcome screen — no dialog |
SessionLockStolenError | Silent abort — another tab took the session lock |
AbortSignal aborted | Silent return — token expiry mid-restore |
hasAccessToken true but no token | StorageEvictedDialog shown (IndexedDB wiped) |
| Crypto store inconsistency | StorageEvictedDialog shown |
| Pickle key decryption fails | "no pickle key found" error → SessionRestoreErrorDialog |
Real-world Failure: gnome_libsecret#
The most common reported failure (particularly on Linux) is :
Unable to load session Error decrypting secret access_token: no pickle key found.
This occurs when Element Desktop uses the gnome_libsecret backend and safeStorage.decryptString() fails — for example after a system upgrade, OS keyring ACL invalidation, or a transient keychain lock. The keyring entries exist and are verifiable via secret-tool, but Electron cannot decrypt them in that session. The current workaround is to clear storage and re-login; PR #33986 targets the root cause to allow recovery on next launch.
Rageshake During Restore Failure#
PR #31848 fixed a crash when users tried to submit a bug report via SessionRestoreErrorDialog while session restore had failed — SettingsStore.getValue() calls in the rageshake path were not guarded against missing client state. Each setting read is now wrapped in try/catch to prevent rageshake submission from itself failing.