diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -12,6 +12,7 @@
   qt-job.cpp
   ${CMAKE_CURRENT_BINARY_DIR}/kazv-version.cpp
   matrix-sdk.cpp
+  kazv-session-lock-guard.cpp
   matrix-room.cpp
   matrix-room-list.cpp
   matrix-room-timeline.cpp
diff --git a/src/kazv-session-lock-guard.hpp b/src/kazv-session-lock-guard.hpp
new file mode 100644
--- /dev/null
+++ b/src/kazv-session-lock-guard.hpp
@@ -0,0 +1,21 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#pragma once
+#include <kazv-defs.hpp>
+#include <memory>
+#include <filesystem>
+
+class KazvSessionLockGuard
+{
+public:
+    KazvSessionLockGuard(const std::filesystem::path &sessionDir);
+    ~KazvSessionLockGuard();
+
+private:
+    struct Private;
+    std::unique_ptr<Private> m_d;
+};
diff --git a/src/kazv-session-lock-guard.cpp b/src/kazv-session-lock-guard.cpp
new file mode 100644
--- /dev/null
+++ b/src/kazv-session-lock-guard.cpp
@@ -0,0 +1,64 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <kazv-defs.hpp>
+#include <kazv-platform.hpp>
+#include <cstring>
+#if !KAZV_IS_WINDOWS
+#include <fcntl.h>
+#include <unistd.h>
+#endif
+#include "kazv-session-lock-guard.hpp"
+#include "kazv-log.hpp"
+
+struct KazvSessionLockGuard::Private
+{
+    std::string lockFilePath;
+    int fd{-1};
+
+    void cleanup()
+    {
+#if !KAZV_IS_WINDOWS
+        qCDebug(kazvLog) << "KazvSessionLockGuard::Private::cleanup" << QString::fromStdString(lockFilePath);
+        if (fd != -1) {
+            qCDebug(kazvLog) << "unlocking session";
+            lockf(fd, F_ULOCK, 0);
+            close(fd);
+            fd = -1;
+        }
+#endif
+    }
+
+    Private(const std::filesystem::path &sessionDir)
+        : lockFilePath((sessionDir / "lock").string())
+    {
+#if !KAZV_IS_WINDOWS
+        qCDebug(kazvLog) << "KazvSessionLockGuard::Private::Private" << QString::fromStdString(lockFilePath);
+        fd = open(lockFilePath.data(), O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
+        if (fd == -1) {
+            throw std::runtime_error{std::string("Cannot open lock file: ") + std::strerror(errno)};
+        }
+
+        auto res = lockf(fd, F_TLOCK, 0);
+        if (res == -1) {
+            cleanup();
+            throw std::runtime_error{std::string("Cannot grab lock file: ") + std::strerror(errno)};
+        }
+#endif
+    }
+
+    ~Private()
+    {
+        cleanup();
+    }
+};
+
+KazvSessionLockGuard::KazvSessionLockGuard(const std::filesystem::path &sessionDir)
+    : m_d(std::make_unique<Private>(sessionDir))
+{
+}
+
+KazvSessionLockGuard::~KazvSessionLockGuard() = default;
diff --git a/src/matrix-sdk.hpp b/src/matrix-sdk.hpp
--- a/src/matrix-sdk.hpp
+++ b/src/matrix-sdk.hpp
@@ -43,6 +43,7 @@
     Q_OBJECT
     QML_ELEMENT
 
+    QString m_userDataDir;
     std::unique_ptr<MatrixSdkPrivate> m_d;
 
     /// @param d A dynamically allocated d-pointer, whose ownership
@@ -78,7 +79,7 @@
 
 private:
     // Replaces the store with another one
-    void emplace(std::optional<Kazv::SdkModel> model);
+    void emplace(std::optional<Kazv::SdkModel> model, std::unique_ptr<KazvSessionLockGuard> lockGuard);
 
 Q_SIGNALS:
     void trigger(Kazv::KazvEvent e);
diff --git a/src/matrix-sdk.cpp b/src/matrix-sdk.cpp
--- a/src/matrix-sdk.cpp
+++ b/src/matrix-sdk.cpp
@@ -44,6 +44,7 @@
 #include "matrix-user-given-attrs-map.hpp"
 #include "kazv-log.hpp"
 #include "matrix-utils.hpp"
+#include "kazv-session-lock-guard.hpp"
 using namespace Kazv;
 
 // Sdk with qt event loop, identity transform and no enhancers
@@ -78,10 +79,11 @@
 
 struct MatrixSdkPrivate
 {
-    MatrixSdkPrivate(MatrixSdk *q, bool testing);
-    MatrixSdkPrivate(MatrixSdk *q, bool testing, SdkModel model);
+    MatrixSdkPrivate(MatrixSdk *q, bool testing, std::unique_ptr<KazvSessionLockGuard> lockGuard);
+    MatrixSdkPrivate(MatrixSdk *q, bool testing, SdkModel model, std::unique_ptr<KazvSessionLockGuard> lockGuard);
     bool testing;
     std::string userDataDir;
+    std::unique_ptr<KazvSessionLockGuard> lockGuard;
     RandomInterface randomGenerator;
     QThread *thread;
     QObject *obj;
@@ -126,25 +128,25 @@
     explicit CleanupHelper(std::unique_ptr<MatrixSdkPrivate> d)
         : oldD(std::move(d))
     {
-        // After the thread's event loop is finished, we can delete the root object
-        connect(oldD->thread, &QThread::finished, oldD->obj, &QObject::deleteLater);
-        connect(oldD->obj, &QObject::destroyed, this, &QObject::deleteLater);
     }
 
-    std::shared_ptr<MatrixSdkPrivate> oldD;
+    std::unique_ptr<MatrixSdkPrivate> oldD;
 
     void cleanup()
     {
         qCInfo(kazvLog) << "start to clean up everything";
         oldD->clientOnSecondaryRoot.stopSyncing()
-            .then([oldD=oldD](auto &&) {
+            .then([obj=oldD->obj, thread=oldD->thread](auto &&) {
                 qCDebug(kazvLog) << "stopped syncing";
-                QMetaObject::invokeMethod(oldD->obj, [thread=oldD->thread]() {
+                QMetaObject::invokeMethod(obj, [thread]() {
                     thread->quit();
                 });
             });
         oldD->thread->wait();
         oldD->thread->deleteLater();
+        // After the thread's event loop is finished, we can delete the root object
+        oldD->obj->deleteLater();
+        this->deleteLater();
         qCInfo(kazvLog) << "thread is done";
     }
 };
@@ -178,6 +180,15 @@
         return;
     }
 
+    if (!lockGuard) {
+        try {
+            lockGuard = std::make_unique<KazvSessionLockGuard>(sessionDir);
+        } catch (const std::runtime_error &e) {
+            qCWarning(kazvLog) << "Error locking session: " << e.what();
+            return;
+        }
+    }
+
     {
         auto storeStream = std::ofstream(storeFile);
         if (! storeStream) {
@@ -199,9 +210,10 @@
     }
 }
 
-MatrixSdkPrivate::MatrixSdkPrivate(MatrixSdk *q, bool testing)
+MatrixSdkPrivate::MatrixSdkPrivate(MatrixSdk *q, bool testing, std::unique_ptr<KazvSessionLockGuard> lockGuard)
     : testing(testing)
     , userDataDir{kazvUserDataDir().toStdString()}
+    , lockGuard(std::move(lockGuard))
     , randomGenerator(QtRandAdapter{})
     , thread(new QThread())
     , obj(new QObject())
@@ -222,9 +234,10 @@
     obj->moveToThread(thread);
 }
 
-MatrixSdkPrivate::MatrixSdkPrivate(MatrixSdk *q, bool testing, SdkModel model)
+MatrixSdkPrivate::MatrixSdkPrivate(MatrixSdk *q, bool testing, SdkModel model, std::unique_ptr<KazvSessionLockGuard> lockGuard)
     : testing(testing)
     , userDataDir{kazvUserDataDir().toStdString()}
+    , lockGuard(std::move(lockGuard))
     , randomGenerator(QtRandAdapter{})
     , thread(new QThread())
     , obj(new QObject())
@@ -247,6 +260,7 @@
 
 MatrixSdk::MatrixSdk(std::unique_ptr<MatrixSdkPrivate> d, QObject *parent)
     : QObject(parent)
+    , m_userDataDir(kazvUserDataDir())
     , m_d(std::move(d))
 {
     init();
@@ -300,12 +314,12 @@
 }
 
 MatrixSdk::MatrixSdk(QObject *parent)
-    : MatrixSdk(std::make_unique<MatrixSdkPrivate>(this, /* testing = */ false), parent)
+    : MatrixSdk(std::make_unique<MatrixSdkPrivate>(this, /* testing = */ false, std::unique_ptr<KazvSessionLockGuard>()), parent)
 {
 }
 
 MatrixSdk::MatrixSdk(SdkModel model, bool testing, QObject *parent)
-    : MatrixSdk(std::make_unique<MatrixSdkPrivate>(this, testing, std::move(model)), parent)
+    : MatrixSdk(std::make_unique<MatrixSdkPrivate>(this, testing, std::move(model), std::unique_ptr<KazvSessionLockGuard>()), parent)
 {
 }
 
@@ -371,7 +385,7 @@
     return new MatrixRoomList(m_d->clientOnSecondaryRoot);
 }
 
-void MatrixSdk::emplace(std::optional<SdkModel> model)
+void MatrixSdk::emplace(std::optional<SdkModel> model, std::unique_ptr<KazvSessionLockGuard> lockGuard)
 {
     auto testing = m_d->testing;
     auto userDataDir = m_d->userDataDir;
@@ -381,8 +395,8 @@
     }
 
     m_d = (model.has_value()
-           ? std::make_unique<MatrixSdkPrivate>(this, testing, std::move(model.value()))
-           : std::make_unique<MatrixSdkPrivate>(this, testing));
+           ? std::make_unique<MatrixSdkPrivate>(this, testing, std::move(model.value()), std::move(lockGuard))
+           : std::make_unique<MatrixSdkPrivate>(this, testing, std::move(lockGuard)));
     m_d->userDataDir = userDataDir;
 
     // Re-initialize lager-qt cursors and watchable connections
@@ -436,7 +450,8 @@
 bool MatrixSdk::loadSession(QString sessionName)
 {
     using StdPath = std::filesystem::path;
-    auto loadFromSession = [this, sessionName](StdPath sessionDir) {
+    std::unique_ptr<KazvSessionLockGuard> lockGuard;
+    auto loadFromSession = [this, sessionName, &lockGuard](StdPath sessionDir) {
         auto storeFile = sessionDir / "store";
 
         auto metadataFile = sessionDir / "metadata";
@@ -488,7 +503,7 @@
             qDebug() << "Error when loading session:" << QString::fromStdString(e.what());
             return false;
         }
-        emplace(std::move(model));
+        emplace(std::move(model), std::move(lockGuard));
         return true;
     };
 
@@ -500,6 +515,12 @@
         auto sessionId = parts[1].toStdString();
         auto encodedUserId = encodeBase64(userId, Base64Opts::urlSafe);
         auto sessionDir = userDataDir / "sessions" / encodedUserId / sessionId;
+        try {
+            lockGuard = std::make_unique<KazvSessionLockGuard>(sessionDir);
+        } catch (const std::runtime_error &e) {
+            qCWarning(kazvLog) << "Error locking session: " << e.what();
+            return false;
+        }
         return loadFromSession(sessionDir);
     }
     qDebug(kazvLog) << "no session found for" << sessionName;
@@ -508,7 +529,7 @@
 
 bool MatrixSdk::startNewSession()
 {
-    emplace(std::nullopt);
+    emplace(std::nullopt, std::unique_ptr<KazvSessionLockGuard>());
     return true;
 }
 
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -9,6 +9,10 @@
 target_include_directories(kazvtestlib PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
 target_link_libraries(kazvtestlib PUBLIC kazvprivlib libkazv::kazvtestfixtures)
 
+add_executable(matrix-sdk-session-loader matrix-sdk-session-loader.cpp matrix-sdk-sessions-test.cpp)
+target_compile_definitions(matrix-sdk-session-loader PRIVATE MATRIX_SDK_SESSIONS_TEST_NO_MAIN)
+target_link_libraries(matrix-sdk-session-loader Qt${QT_MAJOR_VERSION}::Test kazvtestlib)
+
 ecm_add_tests(
   qt-job-handler-test.cpp
   qt-promise-handler-test.cpp
diff --git a/src/tests/matrix-sdk-session-loader.cpp b/src/tests/matrix-sdk-session-loader.cpp
new file mode 100644
--- /dev/null
+++ b/src/tests/matrix-sdk-session-loader.cpp
@@ -0,0 +1,26 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <kazv-defs.hpp>
+#include <iostream>
+#include <QString>
+#include "matrix-sdk-sessions-test.hpp"
+
+int main([[maybe_unused]] int argc, char *argv[])
+{
+    std::string userDataDir = argv[1];
+    std::string sessionName = argv[2];
+    auto sdk = MatrixSdkSessionsTest::makeMatrixSdkImpl(userDataDir);
+    auto res = sdk->loadSession(QString::fromStdString(sessionName));
+    if (!res) {
+        std::cout << "cannot load session" << std::endl;
+        return 1;
+    }
+
+    std::cout << "loaded session" << std::endl;
+    while (1) {}
+    return 0;
+}
diff --git a/src/tests/matrix-sdk-sessions-test.hpp b/src/tests/matrix-sdk-sessions-test.hpp
new file mode 100644
--- /dev/null
+++ b/src/tests/matrix-sdk-sessions-test.hpp
@@ -0,0 +1,47 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#pragma once
+#include <kazv-defs.hpp>
+#include <memory>
+#include <QObject>
+#include <matrix-sdk.hpp>
+
+class MatrixSdkSessionsTest : public QObject
+{
+    Q_OBJECT
+
+private:
+    std::string m_userDataDir;
+
+    void clearDir();
+    void createSession(std::string userId, std::string deviceId);
+
+public:
+    template<class ...Args>
+    static std::unique_ptr<MatrixSdk> makeMatrixSdkImpl(std::string userDataDir, Args &&...args)
+    {
+        std::unique_ptr<MatrixSdk> sdk(new MatrixSdk(std::forward<Args>(args)...));
+        sdk->setUserDataDir(userDataDir);
+        return sdk;
+    }
+
+    template<class ...Args>
+    std::unique_ptr<MatrixSdk> makeMatrixSdk(Args &&...args)
+    {
+        return makeMatrixSdkImpl(m_userDataDir, std::forward<Args>(args)...);
+    }
+
+private Q_SLOTS:
+    void initTestCase();
+    void init();
+    void cleanup();
+
+    void testListSessions();
+    void testListLegacySessions();
+    void testLoadSession();
+    void testSessionLock();
+};
diff --git a/src/tests/matrix-sdk-sessions-test.cpp b/src/tests/matrix-sdk-sessions-test.cpp
--- a/src/tests/matrix-sdk-sessions-test.cpp
+++ b/src/tests/matrix-sdk-sessions-test.cpp
@@ -5,47 +5,22 @@
  */
 
 #include <kazv-defs.hpp>
+#include <kazv-platform.hpp>
 #include <memory>
 #include <filesystem>
 #include <fstream>
 #include <QObject>
 #include <QtTest>
+#include <QProcess>
+#include <QCoreApplication>
 #include <crypto/base64.hpp>
-#include <matrix-sdk.hpp>
 #include "test-temp.hpp"
+#include "matrix-sdk-sessions-test.hpp"
 
 using namespace Kazv;
 namespace Fs = std::filesystem;
 using StdPath = Fs::path;
 
-class MatrixSdkSessionsTest : public QObject
-{
-    Q_OBJECT
-
-private:
-    std::string m_userDataDir;
-
-    void clearDir();
-    void createSession(std::string userId, std::string deviceId);
-
-    template<class ...Args>
-    std::unique_ptr<MatrixSdk> makeMatrixSdk(Args &&...args)
-    {
-        std::unique_ptr<MatrixSdk> sdk(new MatrixSdk(std::forward<Args>(args)...));
-        sdk->setUserDataDir(m_userDataDir);
-        return sdk;
-    }
-
-private Q_SLOTS:
-    void initTestCase();
-    void init();
-    void cleanup();
-
-    void testListSessions();
-    void testListLegacySessions();
-    void testLoadSession();
-};
-
 void MatrixSdkSessionsTest::initTestCase()
 {
     auto dir = StdPath(kazvTestTempDir()) / "sessions-test";
@@ -70,8 +45,13 @@
     SdkModel model;
     model.client.userId = userId;
     model.client.deviceId = deviceId;
-    auto sdk = makeMatrixSdk(model, /* testing = */ false);
-    sdk->serializeToFile();
+    {
+        auto sdk = makeMatrixSdk(model, /* testing = */ false);
+        sdk->serializeToFile();
+    }
+    // Not ideal, but this is the only way to wait for
+    // MatrixSdkPravite to be destroyed.
+    QTest::qWait(100);
 }
 
 void MatrixSdkSessionsTest::testListSessions()
@@ -117,15 +97,44 @@
 
 void MatrixSdkSessionsTest::testLoadSession()
 {
-    createSession("@mew:example.com", "device1");
+    createSession("@mew:example.com", "device4");
 
     auto sdk = makeMatrixSdk();
-    auto res = sdk->loadSession("@mew:example.com/device1");
+    auto res = sdk->loadSession("@mew:example.com/device4");
     QVERIFY(res);
     QCOMPARE(sdk->userId(), QStringLiteral("@mew:example.com"));
-    QCOMPARE(sdk->deviceId(), QStringLiteral("device1"));
+    QCOMPARE(sdk->deviceId(), QStringLiteral("device4"));
 }
 
-QTEST_MAIN(MatrixSdkSessionsTest)
+void MatrixSdkSessionsTest::testSessionLock()
+{
+#if KAZV_IS_WINDOWS
+    QSKIP("Skipping because session lock is not yet supported on Windows");
+#else
+    createSession("@mew:example.com", "device5");
+
+    auto program = QCoreApplication::applicationDirPath() + QStringLiteral("/matrix-sdk-session-loader");
+    QProcess proc1;
+    QStringList args{
+        QString::fromStdString(m_userDataDir),
+        QStringLiteral("@mew:example.com/device5"),
+    };
+    proc1.start(program, args);
+    proc1.waitForReadyRead();
+    auto line = proc1.readLine();
+    QCOMPARE(line, QByteArray("loaded session\n"));
+
+    QProcess proc2;
+    proc2.start(program, args);
+    auto res = proc2.waitForFinished();
+    QVERIFY(res);
+    QCOMPARE(proc2.exitStatus(), QProcess::NormalExit);
+    QCOMPARE(proc2.exitCode(), 1);
 
-#include "matrix-sdk-sessions-test.moc"
+    proc1.kill();
+#endif
+}
+
+#ifndef MATRIX_SDK_SESSIONS_TEST_NO_MAIN
+QTEST_MAIN(MatrixSdkSessionsTest)
+#endif