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 f72d5aac658c7e6b13ba22d1bd52ce2d6e34a548..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,6 +5,7 @@ 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.server.MailboxPrincipal.SetupPrincipal
 import org.briarproject.mailbox.core.setup.SetupManager
 import org.briarproject.mailbox.core.system.RandomIdManager
 import javax.inject.Inject
@@ -25,11 +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 {
-                if (token == setupManager.getOwnerToken(txn)) OwnerPrincipal
-                else null
+            when {
+                contact != null -> ContactPrincipal(contact)
+                setupManager.getOwnerToken(txn) == token -> OwnerPrincipal
+                setupManager.getSetupToken(txn) == token -> SetupPrincipal
+                else -> null
             }
         }
     }
@@ -79,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/setup/SetupManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/setup/SetupManager.kt
index 14002b3a48905b5d5ab2fe992e69f6905cb95d81..611dccf6772b0093776015d4cf365f3852262cf9 100644
--- 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
@@ -1,7 +1,14 @@
 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
@@ -22,26 +29,68 @@ class SetupManager @Inject constructor(
     fun restartSetup() {
         val settings = Settings()
         settings[SETTINGS_SETUP_TOKEN] = randomIdManager.getNewRandomId()
-        settings[SETTINGS_OWNER_TOKEN] = "" // we can't remove or null or, so we need to empty it
+        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 setOwnerToken(token: String) {
+    internal fun setToken(setupToken: String?, ownerToken: String?) {
         val settings = Settings()
-        settings[SETTINGS_OWNER_TOKEN] = token
+        if (setupToken == null) {
+            settings[SETTINGS_SETUP_TOKEN] = ""
+        } else {
+            randomIdManager.assertIsRandomId(setupToken)
+            settings[SETTINGS_SETUP_TOKEN] = setupToken
+        }
+        if (ownerToken == null) {
+            settings[SETTINGS_OWNER_TOKEN] = ""
+        } else {
+            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].nullify()
+    }
+
     @Throws(DbException::class)
     fun getOwnerToken(txn: Transaction): String? {
         val settings = settingsManager.getSettings(txn, SETTINGS_NAMESPACE_OWNER)
-        val ownerToken = settings[SETTINGS_OWNER_TOKEN]
-        return if (ownerToken.isNullOrEmpty()) null
-        else ownerToken
+        return settings[SETTINGS_OWNER_TOKEN].nullify()
+    }
+
+    /**
+     * @return the same string or null if it is empty
+     */
+    private fun String?.nullify(): String? {
+        return if (isNullOrEmpty()) null
+        else this
     }
 
 }
+
+internal data class SetupResponse(val token: String)
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 6eaf19addf139cab6d53819bfee0fc73ba84265c..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,6 +7,7 @@ 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.server.MailboxPrincipal.SetupPrincipal
 import org.briarproject.mailbox.core.setup.SetupManager
 import org.briarproject.mailbox.core.system.InvalidIdException
 import org.briarproject.mailbox.core.system.RandomIdManager
@@ -61,11 +62,23 @@ class AuthManagerTest {
         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 { setupManager.getOwnerToken(txn) } returns otherId
+            every { setupManager.getSetupToken(txn) } returns id
+        }
+
+        assertEquals(SetupPrincipal, authManager.getPrincipal(id))
+    }
+
     @Test
     fun `assertCanDownloadFromFolder() throws for null MailboxPrincipal`() {
         assertThrows<AuthException> {
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 140ef2e3aed6b908a02b841ba48587f6911bcf9a..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
@@ -57,7 +57,7 @@ abstract class IntegrationTest(private val installJsonFeature: Boolean = true) {
     }
 
     protected fun addOwnerToken() {
-        testComponent.getSetupManager().setOwnerToken(ownerToken)
+        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
index 88470f41171f36d77efb80976f96e04e2b938941..167cd18bb7da2957e4796cdf6b1de2532eb191a0 100644
--- 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
@@ -1,8 +1,14 @@
 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() {
@@ -10,27 +16,85 @@ 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`() {
-        // initially, there's no owner token
-        val initialOwnerToken = db.transactionWithResult(true) { txn ->
-            setupManager.getOwnerToken(txn)
+    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))
         }
-        assertNull(initialOwnerToken)
 
         // setting an owner token stores it in DB
-        setupManager.setOwnerToken(ownerToken)
-        val firstOwnerToken = db.transactionWithResult(true) { txn ->
-            setupManager.getOwnerToken(txn)
+        setupManager.setToken(null, ownerToken)
+        db.transaction(true) { txn ->
+            assertNull(setupManager.getSetupToken(txn))
+            assertEquals(ownerToken, setupManager.getOwnerToken(txn))
         }
-        assertEquals(ownerToken, firstOwnerToken)
 
-        // restarting setup wipes owner token
+        // restarting setup wipes owner token, creates setup token
         setupManager.restartSetup()
-        val wipedOwnerToken = db.transactionWithResult(true) { txn ->
-            setupManager.getOwnerToken(txn)
+        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)
         }
-        assertNull(wipedOwnerToken)
     }
 
-}
\ No newline at end of file
+}
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>