diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/AuthManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/AuthManager.kt
index 455bdff772511e233b603a562bd2ae7674aa0d6f..3c8301296828eb018621f9eb26fc84e646757b83 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/AuthManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/AuthManager.kt
@@ -5,19 +5,16 @@ import org.briarproject.mailbox.core.contacts.Contact
 import org.briarproject.mailbox.core.db.Database
 import org.briarproject.mailbox.core.server.MailboxPrincipal.ContactPrincipal
 import org.briarproject.mailbox.core.server.MailboxPrincipal.OwnerPrincipal
-import org.briarproject.mailbox.core.settings.SettingsManager
+import org.briarproject.mailbox.core.server.MailboxPrincipal.SetupPrincipal
+import org.briarproject.mailbox.core.setup.SetupManager
 import org.briarproject.mailbox.core.system.RandomIdManager
 import javax.inject.Inject
 import javax.inject.Singleton
 
-// We might want to move this somewhere else later
-internal const val SETTINGS_NAMESPACE_OWNER = "owner"
-internal const val SETTINGS_OWNER_TOKEN = "ownerToken"
-
 @Singleton
 class AuthManager @Inject constructor(
     private val db: Database,
-    private val settingsManager: SettingsManager,
+    private val setupManager: SetupManager,
     private val randomIdManager: RandomIdManager,
 ) {
 
@@ -29,12 +26,11 @@ class AuthManager @Inject constructor(
         randomIdManager.assertIsRandomId(token)
         return db.transactionWithResult(true) { txn ->
             val contact = db.getContactWithToken(txn, token)
-            if (contact != null) {
-                ContactPrincipal(contact)
-            } else {
-                val settings = settingsManager.getSettings(txn, SETTINGS_NAMESPACE_OWNER)
-                if (token == settings[SETTINGS_OWNER_TOKEN]) OwnerPrincipal
-                else null
+            when {
+                contact != null -> ContactPrincipal(contact)
+                setupManager.getOwnerToken(txn) == token -> OwnerPrincipal
+                setupManager.getSetupToken(txn) == token -> SetupPrincipal
+                else -> null
             }
         }
     }
@@ -84,6 +80,7 @@ class AuthManager @Inject constructor(
 }
 
 sealed class MailboxPrincipal : Principal {
+    object SetupPrincipal : MailboxPrincipal()
     object OwnerPrincipal : MailboxPrincipal()
     data class ContactPrincipal(val contact: Contact) : MailboxPrincipal()
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt
index 80ef74aba57f4588afe0a0ac48e7307c3f3f4606..007ae9a7881cbd8119c3f07cdfc91f8e076e94da 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt
@@ -21,11 +21,12 @@ import io.ktor.routing.routing
 import io.ktor.util.getOrFail
 import org.briarproject.mailbox.core.contacts.ContactsManager
 import org.briarproject.mailbox.core.files.FileManager
+import org.briarproject.mailbox.core.setup.SetupManager
 import org.briarproject.mailbox.core.system.InvalidIdException
 
 internal const val V = "/" // TODO set to "/v1" for release
 
-internal fun Application.configureBasicApi() = routing {
+internal fun Application.configureBasicApi(setupManager: SetupManager) = routing {
     route(V) {
         get {
             call.respondText("Hello world!", ContentType.Text.Plain)
@@ -35,7 +36,9 @@ internal fun Application.configureBasicApi() = routing {
                 call.respond(HttpStatusCode.OK, "delete: Not yet implemented")
             }
             put("/setup") {
-                call.respond(HttpStatusCode.OK, "put: Not yet implemented")
+                call.handle {
+                    setupManager.onSetupRequest(call)
+                }
             }
         }
     }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
index 82140aa955e54402e868e9e476c2cb82406ec1fa..771167d3aa077a542167416c5dafd3cf7a329313 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt
@@ -11,6 +11,7 @@ import org.briarproject.mailbox.core.contacts.ContactsManager
 import org.briarproject.mailbox.core.files.FileManager
 import org.briarproject.mailbox.core.lifecycle.Service
 import org.briarproject.mailbox.core.server.WebServerManager.Companion.PORT
+import org.briarproject.mailbox.core.setup.SetupManager
 import org.slf4j.Logger
 import org.slf4j.LoggerFactory.getLogger
 import javax.inject.Inject
@@ -25,6 +26,7 @@ interface WebServerManager : Service {
 @Singleton
 internal class WebServerManagerImpl @Inject constructor(
     private val authManager: AuthManager,
+    private val setupManager: SetupManager,
     private val contactsManager: ContactsManager,
     private val fileManager: FileManager,
 ) : WebServerManager {
@@ -48,7 +50,7 @@ internal class WebServerManagerImpl @Inject constructor(
             install(ContentNegotiation) {
                 jackson()
             }
-            configureBasicApi()
+            configureBasicApi(setupManager)
             configureContactApi(contactsManager)
             configureFilesApi(fileManager)
         }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/Settings.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/Settings.kt
index 8e988a443a943dfe829b7becab2dd2e66bd40d5e..01ace732dbcdb82e04f1abaf9034819350121509 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/Settings.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/settings/Settings.kt
@@ -4,6 +4,17 @@ import java.util.Hashtable
 
 class Settings : Hashtable<String, String>() {
 
+    override fun put(key: String, value: String?): String? {
+        return if (value == null) super.put(key, "")
+        else super.put(key, value)
+    }
+
+    override fun get(key: String): String? {
+        val value = super.get(key)
+        return if (value.isNullOrEmpty()) null
+        else value
+    }
+
     fun getBoolean(key: String, defaultValue: Boolean): Boolean {
         val s = get(key) ?: return defaultValue
         if ("true" == s) return true
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
new file mode 100644
index 0000000000000000000000000000000000000000..37e4f518148de33390e4de313ce118cf40f8a31b
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
@@ -0,0 +1,80 @@
+package org.briarproject.mailbox.core.setup
+
+import io.ktor.application.ApplicationCall
+import io.ktor.auth.principal
+import io.ktor.http.HttpStatusCode
+import io.ktor.response.respond
+import org.briarproject.mailbox.core.db.DbException
+import org.briarproject.mailbox.core.db.Transaction
+import org.briarproject.mailbox.core.server.AuthException
+import org.briarproject.mailbox.core.server.MailboxPrincipal
+import org.briarproject.mailbox.core.server.MailboxPrincipal.SetupPrincipal
+import org.briarproject.mailbox.core.settings.Settings
+import org.briarproject.mailbox.core.settings.SettingsManager
+import org.briarproject.mailbox.core.system.RandomIdManager
+import javax.inject.Inject
+
+private const val SETTINGS_NAMESPACE_OWNER = "owner"
+private const val SETTINGS_SETUP_TOKEN = "setupToken"
+private const val SETTINGS_OWNER_TOKEN = "ownerToken"
+
+class SetupManager @Inject constructor(
+    private val randomIdManager: RandomIdManager,
+    private val settingsManager: SettingsManager,
+) {
+
+    /**
+     * Stores a new single-use setup token and wipes the owner auth token, if one existed.
+     */
+    fun restartSetup() {
+        val settings = Settings()
+        settings[SETTINGS_SETUP_TOKEN] = randomIdManager.getNewRandomId()
+        settings[SETTINGS_OWNER_TOKEN] = "" // we can't remove or null, so we need to empty it
+        settingsManager.mergeSettings(settings, SETTINGS_NAMESPACE_OWNER)
+    }
+
+    /**
+     * Handler for `PUT /setup` API endpoint.
+     *
+     * Wipes setup token and responds with new owner token and 201 status code.
+     */
+    @Throws(AuthException::class)
+    suspend fun onSetupRequest(call: ApplicationCall) {
+        val principal: MailboxPrincipal? = call.principal()
+        if (principal !is SetupPrincipal) throw AuthException()
+
+        // set new owner token and clear single-use setup token
+        val ownerToken = randomIdManager.getNewRandomId()
+        setToken(null, ownerToken)
+        val response = SetupResponse(ownerToken)
+
+        call.respond(HttpStatusCode.Created, response)
+    }
+
+    /**
+     * Visible for testing, consider private.
+     */
+    @Throws(DbException::class)
+    internal fun setToken(setupToken: String?, ownerToken: String?) {
+        val settings = Settings()
+        if (setupToken != null) randomIdManager.assertIsRandomId(setupToken)
+        settings[SETTINGS_SETUP_TOKEN] = setupToken
+        if (ownerToken != null) randomIdManager.assertIsRandomId(ownerToken)
+        settings[SETTINGS_OWNER_TOKEN] = ownerToken
+        settingsManager.mergeSettings(settings, SETTINGS_NAMESPACE_OWNER)
+    }
+
+    fun getSetupToken(txn: Transaction): String? {
+        val settings = settingsManager.getSettings(txn, SETTINGS_NAMESPACE_OWNER)
+        return settings[SETTINGS_SETUP_TOKEN]
+    }
+
+    @Throws(DbException::class)
+    fun getOwnerToken(txn: Transaction): String? {
+        val settings = settingsManager.getSettings(txn, SETTINGS_NAMESPACE_OWNER)
+        return settings[SETTINGS_OWNER_TOKEN]
+    }
+
+}
+
+internal data class SetupResponse(val token: String)
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
index c374303c762f7845193081bf282b218a0dca1830..564531b71e307b705f0864f5c9559cc752fc057a 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestComponent.kt
@@ -5,6 +5,7 @@ import org.briarproject.mailbox.core.db.Database
 import org.briarproject.mailbox.core.files.FileManager
 import org.briarproject.mailbox.core.lifecycle.LifecycleManager
 import org.briarproject.mailbox.core.settings.SettingsManager
+import org.briarproject.mailbox.core.setup.SetupManager
 import org.briarproject.mailbox.core.system.RandomIdManager
 import javax.inject.Singleton
 
@@ -18,6 +19,7 @@ interface TestComponent {
     fun injectCoreEagerSingletons(): CoreEagerSingletons
     fun getLifecycleManager(): LifecycleManager
     fun getSettingsManager(): SettingsManager
+    fun getSetupManager(): SetupManager
     fun getFileManager(): FileManager
     fun getDatabase(): Database
     fun getRandomIdManager(): RandomIdManager
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/AuthManagerTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/AuthManagerTest.kt
index 1a3d6f5b0992b952674f1771900e817c070f5935..397b11f860268b36d1a928080918db245d66fdc8 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/AuthManagerTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/AuthManagerTest.kt
@@ -7,8 +7,8 @@ import org.briarproject.mailbox.core.TestUtils.getNewRandomContact
 import org.briarproject.mailbox.core.TestUtils.getNewRandomId
 import org.briarproject.mailbox.core.db.Database
 import org.briarproject.mailbox.core.server.MailboxPrincipal.OwnerPrincipal
-import org.briarproject.mailbox.core.settings.Settings
-import org.briarproject.mailbox.core.settings.SettingsManager
+import org.briarproject.mailbox.core.server.MailboxPrincipal.SetupPrincipal
+import org.briarproject.mailbox.core.setup.SetupManager
 import org.briarproject.mailbox.core.system.InvalidIdException
 import org.briarproject.mailbox.core.system.RandomIdManager
 import org.briarproject.mailbox.core.system.toHex
@@ -21,10 +21,10 @@ import kotlin.test.assertNull
 class AuthManagerTest {
 
     private val db: Database = mockk()
-    private val settingsManager: SettingsManager = mockk()
+    private val setupManager: SetupManager = mockk()
     private val randomIdManager = RandomIdManager()
 
-    private val authManager = AuthManager(db, settingsManager, randomIdManager)
+    private val authManager = AuthManager(db, setupManager, randomIdManager)
 
     private val id = getNewRandomId()
     private val otherId = getNewRandomId()
@@ -49,13 +49,9 @@ class AuthManagerTest {
 
     @Test
     fun `getPrincipal() returns authenticated owner`() {
-        val settings = Settings().apply {
-            put(SETTINGS_OWNER_TOKEN, id)
-        }
-
         everyTransactionWithResult(db, true) { txn ->
             every { db.getContactWithToken(txn, id) } returns null
-            every { settingsManager.getSettings(txn, SETTINGS_NAMESPACE_OWNER) } returns settings
+            every { setupManager.getOwnerToken(txn) } returns id
         }
 
         assertEquals(OwnerPrincipal, authManager.getPrincipal(id))
@@ -63,16 +59,24 @@ class AuthManagerTest {
 
     @Test
     fun `getPrincipal() returns null when unauthenticated`() {
-        val settings = Settings().apply {
-            put(SETTINGS_OWNER_TOKEN, otherId)
+        everyTransactionWithResult(db, true) { txn ->
+            every { db.getContactWithToken(txn, id) } returns null
+            every { setupManager.getOwnerToken(txn) } returns otherId
+            every { setupManager.getSetupToken(txn) } returns otherId
         }
 
+        assertNull(authManager.getPrincipal(id))
+    }
+
+    @Test
+    fun `getPrincipal() returns SetupPrincipal`() {
         everyTransactionWithResult(db, true) { txn ->
             every { db.getContactWithToken(txn, id) } returns null
-            every { settingsManager.getSettings(txn, SETTINGS_NAMESPACE_OWNER) } returns settings
+            every { setupManager.getOwnerToken(txn) } returns otherId
+            every { setupManager.getSetupToken(txn) } returns id
         }
 
-        assertNull(authManager.getPrincipal(id))
+        assertEquals(SetupPrincipal, authManager.getPrincipal(id))
     }
 
     @Test
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt
index d963bd1161b04bc19120967702551c9beb75076b..238b39da86cb3c695f34cbd81a88b9428f083891 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt
@@ -14,7 +14,6 @@ import org.briarproject.mailbox.core.TestUtils.getNewRandomContact
 import org.briarproject.mailbox.core.TestUtils.getNewRandomId
 import org.briarproject.mailbox.core.contacts.Contact
 import org.briarproject.mailbox.core.server.WebServerManager.Companion.PORT
-import org.briarproject.mailbox.core.settings.Settings
 import org.junit.jupiter.api.AfterAll
 import org.junit.jupiter.api.BeforeAll
 import org.junit.jupiter.api.TestInstance
@@ -58,10 +57,7 @@ abstract class IntegrationTest(private val installJsonFeature: Boolean = true) {
     }
 
     protected fun addOwnerToken() {
-        val settingsManager = testComponent.getSettingsManager()
-        val settings = Settings()
-        settings[SETTINGS_OWNER_TOKEN] = ownerToken
-        settingsManager.mergeSettings(settings, SETTINGS_NAMESPACE_OWNER)
+        testComponent.getSetupManager().setToken(null, ownerToken)
     }
 
     protected fun addContact(c: Contact) {
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/SetupManagerTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/SetupManagerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9ee9c88e122bfa60e57210e25dcb1f64074e3888
--- /dev/null
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/SetupManagerTest.kt
@@ -0,0 +1,109 @@
+package org.briarproject.mailbox.core.setup
+
+import io.ktor.client.request.put
+import io.ktor.client.statement.HttpResponse
+import io.ktor.http.HttpStatusCode
+import kotlinx.coroutines.runBlocking
+import org.briarproject.mailbox.core.server.IntegrationTest
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class SetupManagerTest : IntegrationTest() {
+
+    private val db by lazy { testComponent.getDatabase() }
+    private val setupManager by lazy { testComponent.getSetupManager() }
+
+    @AfterEach
+    fun resetToken() {
+        // re-set both token to not interfere with other tests
+        setupManager.setToken(null, null)
+    }
+
+    @Test
+    fun `restarting setup wipes owner token and creates setup token`() {
+        // initially, there's no setup and no owner token
+        db.transaction(true) { txn ->
+            assertNull(setupManager.getSetupToken(txn))
+            assertNull(setupManager.getOwnerToken(txn))
+        }
+
+        // setting an owner token stores it in DB
+        setupManager.setToken(null, ownerToken)
+        db.transaction(true) { txn ->
+            assertNull(setupManager.getSetupToken(txn))
+            assertEquals(ownerToken, setupManager.getOwnerToken(txn))
+        }
+
+        // restarting setup wipes owner token, creates setup token
+        setupManager.restartSetup()
+        db.transaction(true) { txn ->
+            val setupToken = setupManager.getSetupToken(txn)
+            assertNotNull(setupToken)
+            testComponent.getRandomIdManager().assertIsRandomId(setupToken)
+            assertNull(setupManager.getOwnerToken(txn))
+        }
+    }
+
+    @Test
+    fun `setup request gets rejected when using non-setup token`() = runBlocking {
+        // initially, there's no setup and no owner token
+        db.transaction(true) { txn ->
+            assertNull(setupManager.getSetupToken(txn))
+            assertNull(setupManager.getOwnerToken(txn))
+        }
+        // owner token gets rejected
+        assertEquals(
+            HttpStatusCode.Unauthorized,
+            httpClient.put<HttpResponse>("$baseUrl/setup") {
+                authenticateWithToken(ownerToken)
+            }.status
+        )
+
+        // now we set the owner token which still gets rejected
+        setupManager.setToken(null, ownerToken)
+        assertEquals(
+            HttpStatusCode.Unauthorized,
+            httpClient.put<HttpResponse>("$baseUrl/setup") {
+                authenticateWithToken(ownerToken)
+            }.status
+        )
+
+        // now we set the setup token, but use a different one for the request, so it gets rejected
+        setupManager.setToken(token, null)
+        assertEquals(
+            HttpStatusCode.Unauthorized,
+            httpClient.put<HttpResponse>("$baseUrl/setup") {
+                authenticateWithToken(ownerToken)
+            }.status
+        )
+    }
+
+    @Test
+    fun `setup request clears setup token and sets new owner token`() = runBlocking {
+        // set a setup-token
+        setupManager.setToken(token, null)
+
+        // use it for setup PUT request
+        val response: SetupResponse = httpClient.put("$baseUrl/setup") {
+            authenticateWithToken(token)
+        }
+        // setup token got wiped and new owner token from response got stored
+        db.transaction(true) { txn ->
+            assertNull(setupManager.getSetupToken(txn))
+            assertEquals(setupManager.getOwnerToken(txn), response.token)
+        }
+    }
+
+    @Test
+    fun `authentication doesn't work with empty string`() = runBlocking {
+        // use it for setup PUT request
+        val response: HttpResponse = httpClient.put("$baseUrl/setup") {
+            authenticateWithToken("")
+        }
+        assertEquals(HttpStatusCode.Unauthorized, response.status)
+    }
+
+}
diff --git a/mailbox-core/src/test/resources/logback.xml b/mailbox-core/src/test/resources/logback.xml
index 945c218bb1ab715b006497a8900d0954aeb3a5a4..f53439b24f525bb17fe9029f307b4bee40e8e35c 100644
--- a/mailbox-core/src/test/resources/logback.xml
+++ b/mailbox-core/src/test/resources/logback.xml
@@ -9,4 +9,5 @@
     </root>
     <logger name="org.eclipse.jetty" level="INFO"/>
     <logger name="io.netty" level="INFO"/>
+    <logger name="io.mockk" level="INFO" />
 </configuration>