diff --git a/build.gradle b/build.gradle
index c4af8a64492ab7e43c17cf37458b714df3e4e6cb..85ad63cd5782cd2b608a5946fbd5bb1487255ddb 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,5 +1,5 @@
 buildscript {
-    ext.kotlin_version = '1.5.21'
+    ext.kotlin_version = '1.5.30'
     ext.hilt_version = '2.38.1'
     ext.tor_version = '0.3.5.15'
     ext.obfs4_version = '0.0.12-dev-40245c4a'
@@ -11,7 +11,7 @@ buildscript {
         mavenCentral()
     }
     dependencies {
-        classpath 'com.android.tools.build:gradle:7.0.0'
+        classpath 'com.android.tools.build:gradle:7.0.1'
         classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
         classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
     }
diff --git a/mailbox-core/build.gradle b/mailbox-core/build.gradle
index 33f0927ea6db736a0c4093673186d6525bff656e..cbf2b5ce91682019cceb3b02207fc2646bfa496b 100644
--- a/mailbox-core/build.gradle
+++ b/mailbox-core/build.gradle
@@ -19,9 +19,10 @@ dependencies {
 
     implementation 'org.briarproject:jtorctl:0.3'
 
-    def ktorVersion = '1.6.2'
-    implementation "io.ktor:ktor-server-core:$ktorVersion"
-    implementation "io.ktor:ktor-server-netty:$ktorVersion"
+    def ktor_version = '1.6.2'
+    implementation "io.ktor:ktor-server-core:$ktor_version"
+    implementation "io.ktor:ktor-server-netty:$ktor_version"
+    implementation "io.ktor:ktor-auth:$ktor_version"
     api "org.slf4j:slf4j-api:1.7.32"
 
     implementation 'com.h2database:h2:1.4.192' // The last version that supports Java 1.6
@@ -32,7 +33,7 @@ dependencies {
     testImplementation "org.junit.jupiter:junit-jupiter-engine:$junit_version"
     testImplementation "io.mockk:mockk:$mockk_version"
     testImplementation "ch.qos.logback:logback-classic:1.2.5"
-    testImplementation "io.ktor:ktor-client-cio:$ktorVersion"
+    testImplementation "io.ktor:ktor-client-cio:$ktor_version"
     testImplementation "com.google.dagger:hilt-core:$hilt_version"
     kaptTest "com.google.dagger:dagger-compiler:$hilt_version"
 }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/AuthenticationManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/AuthenticationManager.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1f5ee5ad3e85c41fd5dfe592ccbae89c83395e71
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/AuthenticationManager.kt
@@ -0,0 +1,23 @@
+package org.briarproject.mailbox.core.server
+
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AuthenticationManager @Inject constructor() {
+
+    fun canOwnerAccess(credentials: Credentials): Boolean {
+        // TODO check credentials:
+        //  * token must be from owner
+        //  * if credentials.mailboxId is not null, must have accessType right to mailboxId
+        return credentials.token == "test123"
+    }
+
+    fun canOwnerOrContactAccess(credentials: Credentials): Boolean {
+        require(credentials.mailboxId != null)
+        // TODO check credentials:
+        //  * token must have credentials.accessType right to mailboxId
+        return credentials.token == "test123" || credentials.token == "test124"
+    }
+
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/BearerAuthenticationProvider.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/BearerAuthenticationProvider.kt
new file mode 100644
index 0000000000000000000000000000000000000000..22330145be34f24d3a7b3a862586e08d69bbd4cd
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/BearerAuthenticationProvider.kt
@@ -0,0 +1,126 @@
+package org.briarproject.mailbox.core.server
+
+import io.ktor.application.ApplicationCall
+import io.ktor.application.call
+import io.ktor.auth.Authentication
+import io.ktor.auth.AuthenticationContext
+import io.ktor.auth.AuthenticationFailedCause
+import io.ktor.auth.AuthenticationFunction
+import io.ktor.auth.AuthenticationPipeline
+import io.ktor.auth.AuthenticationProvider
+import io.ktor.auth.Principal
+import io.ktor.auth.UnauthorizedResponse
+import io.ktor.auth.parseAuthorizationHeader
+import io.ktor.http.auth.HttpAuthHeader
+import io.ktor.request.ApplicationRequest
+import io.ktor.request.httpMethod
+import io.ktor.response.respond
+import org.briarproject.mailbox.core.util.LogUtils.debug
+import org.slf4j.LoggerFactory.getLogger
+
+private val BearerAuthKey: Any = "BearerAuth"
+private val LOG = getLogger(BearerAuthenticationProvider::class.java)
+
+internal class BearerAuthenticationProvider constructor(config: Configuration) :
+    AuthenticationProvider(config) {
+
+    internal var realm: String = config.realm ?: "Ktor Server"
+    internal val authHeader: (ApplicationCall) -> HttpAuthHeader? = config.authHeader
+    internal val authenticationFunction = config.authenticationFunction
+
+    internal class Configuration internal constructor(name: String?) :
+        AuthenticationProvider.Configuration(name) {
+
+        var realm: String? = null
+        val authHeader: (ApplicationCall) -> HttpAuthHeader? = { call ->
+            call.request.parseAuthorizationHeaderOrNull()
+        }
+        var authenticationFunction: AuthenticationFunction<Credentials> = {
+            throw NotImplementedError(
+                "Bearer auth validate function is not specified." +
+                    "Use bearer { validate { ... } } to fix."
+            )
+        }
+
+        /**
+         * Apply [validate] function to every call with [String]
+         * @return a principal (usually an instance of [Principal]) or `null`
+         */
+        fun validate(validate: suspend ApplicationCall.(Credentials) -> Principal?) {
+            authenticationFunction = validate
+        }
+
+        internal fun build() = BearerAuthenticationProvider(this)
+    }
+
+}
+
+/**
+ * Installs Bearer token Authentication mechanism
+ */
+internal fun Authentication.Configuration.bearer(
+    name: String? = null,
+    configure: BearerAuthenticationProvider.Configuration.() -> Unit,
+) {
+    val provider = BearerAuthenticationProvider.Configuration(name).apply(configure).build()
+    provider.pipeline.intercept(AuthenticationPipeline.RequestAuthentication) { context ->
+        val authHeader = provider.authHeader(call)
+        if (authHeader == null) {
+            context.unauthorizedResponse(AuthenticationFailedCause.NoCredentials, provider)
+            return@intercept
+        }
+
+        try {
+            // TODO try faking accessType with X-Http-Method-Override header
+            val accessType = call.request.httpMethod.toAccessType()
+            val token = (authHeader as? HttpAuthHeader.Single)?.blob
+            if (accessType == null || token == null) {
+                context.unauthorizedResponse(AuthenticationFailedCause.InvalidCredentials, provider)
+                return@intercept
+            }
+            val mailboxId = call.parameters["mailboxId"]
+
+            LOG.debug("name: $name")
+            LOG.debug("httpMethod: ${call.request.httpMethod}")
+
+            val credentials = Credentials(accessType, token, mailboxId)
+            val principal = provider.authenticationFunction(call, credentials)
+            if (principal == null) {
+                context.unauthorizedResponse(AuthenticationFailedCause.InvalidCredentials, provider)
+            } else {
+                context.principal(principal)
+            }
+        } catch (cause: Throwable) {
+            val message = cause.message ?: cause.javaClass.simpleName
+            LOG.debug { "Bearer verification failed: $message" }
+            context.error(BearerAuthKey, AuthenticationFailedCause.Error(message))
+        }
+    }
+    register(provider)
+}
+
+private fun AuthenticationContext.unauthorizedResponse(
+    cause: AuthenticationFailedCause,
+    provider: BearerAuthenticationProvider,
+) {
+    challenge(BearerAuthKey, cause) {
+        call.respond(
+            UnauthorizedResponse(
+                HttpAuthHeader.Parameterized(
+                    authScheme = "Bearer",
+                    parameters = mapOf(HttpAuthHeader.Parameters.Realm to provider.realm)
+                )
+            )
+        )
+        if (!it.completed && call.response.status() != null) {
+            it.complete()
+        }
+    }
+}
+
+private fun ApplicationRequest.parseAuthorizationHeaderOrNull() = try {
+    parseAuthorizationHeader()
+} catch (ex: IllegalArgumentException) {
+    LOG.warn("Illegal HTTP auth header", ex)
+    null
+}
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Credentials.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Credentials.kt
new file mode 100644
index 0000000000000000000000000000000000000000..55c63079341959304fb46f5d9e03110e879b8e95
--- /dev/null
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Credentials.kt
@@ -0,0 +1,24 @@
+package org.briarproject.mailbox.core.server
+
+import io.ktor.http.HttpMethod
+
+data class Credentials(
+    val accessType: AccessType,
+    val token: String,
+    val mailboxId: String?,
+)
+
+enum class AccessType { UPLOAD, DOWNLOAD_DELETE }
+
+internal fun HttpMethod.toAccessType(): AccessType? = when (this) {
+    HttpMethod.Get -> AccessType.DOWNLOAD_DELETE
+    HttpMethod.Delete -> AccessType.DOWNLOAD_DELETE
+    HttpMethod.Post -> AccessType.UPLOAD
+    HttpMethod.Put -> AccessType.UPLOAD
+    else -> null
+}
+
+internal object AuthContext {
+    const val ownerOnly = "ownerOnly"
+    const val ownerAndContacts = "ownerAndContacts"
+}
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 2c0aa0cef0aa271230c322ec9262d53d6d088398..c48ac4de9c5dd74a8347873fed6c4614f8d19d41 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
@@ -2,7 +2,10 @@ package org.briarproject.mailbox.core.server
 
 import io.ktor.application.Application
 import io.ktor.application.call
+import io.ktor.auth.authenticate
 import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.response.respond
 import io.ktor.response.respondText
 import io.ktor.routing.delete
 import io.ktor.routing.get
@@ -14,65 +17,86 @@ import io.ktor.routing.routing
 internal const val V = "/" // TODO set to "/v1" for release
 
 internal fun Application.configureBasicApi() = routing {
-
     route("$V/") {
         get {
             call.respondText("Hello world!", ContentType.Text.Plain)
         }
-        delete {
-            TODO("Not yet implemented")
+        authenticate(AuthContext.ownerOnly) {
+            delete {
+                call.respond(HttpStatusCode.OK, "delete: Not yet implemented")
+            }
+            put("setup") {
+                call.respond(HttpStatusCode.OK, "put: Not yet implemented")
+            }
         }
     }
-
-    put("$V/setup") {
-        TODO("Not yet implemented")
-    }
-
 }
 
 internal fun Application.configureContactApi() = routing {
-
-    route("$V/contacts") {
-        put("{contactId}") {
-            TODO("Not yet implemented. contactId: ${call.parameters["contactId"]}")
-        }
-        delete("{contactId}") {
-            TODO("Not yet implemented. contactId: ${call.parameters["contactId"]}")
-        }
-        get {
-            TODO("Not yet implemented")
+    authenticate(AuthContext.ownerOnly) {
+        route("$V/contacts") {
+            put("{contactId}") {
+                call.respond(
+                    HttpStatusCode.OK,
+                    "get: Not yet implemented. " +
+                        "contactId: ${call.parameters["contactId"]}"
+                )
+            }
+            delete("{contactId}") {
+                call.respond(
+                    HttpStatusCode.OK,
+                    "delete: Not yet implemented. " +
+                        "contactId: ${call.parameters["contactId"]}"
+                )
+            }
+            get {
+                call.respond(HttpStatusCode.OK, "get: Not yet implemented")
+            }
         }
     }
-
 }
 
 internal fun Application.configureFilesApi() = routing {
 
-    route("$V/files/{mailboxId}") {
-        post {
-            TODO("Not yet implemented. mailboxId: ${call.parameters["mailboxId"]}")
-        }
-        get {
-            TODO("Not yet implemented. mailboxId: ${call.parameters["mailboxId"]}")
-        }
-        route("/{fileId}") {
-            get {
-                TODO(
-                    "Not yet implemented. mailboxId: ${call.parameters["mailboxId"]}" +
-                        "fileId: ${call.parameters["fileId"]}"
+    authenticate(AuthContext.ownerAndContacts) {
+        route("$V/files/{mailboxId}") {
+            post {
+                call.respond(
+                    HttpStatusCode.OK,
+                    "post: Not yet implemented. " +
+                        "mailboxId: ${call.parameters["mailboxId"]}"
                 )
             }
-            delete {
-                TODO(
-                    "Not yet implemented. mailboxId: ${call.parameters["mailboxId"]}" +
-                        "fileId: ${call.parameters["fileId"]}"
+            get {
+                call.respond(
+                    HttpStatusCode.OK,
+                    "get: Not yet implemented. " +
+                        "mailboxId: ${call.parameters["mailboxId"]}"
                 )
             }
+            route("/{fileId}") {
+                get {
+                    call.respond(
+                        HttpStatusCode.OK,
+                        "get: Not yet implemented. " +
+                            "mailboxId: ${call.parameters["mailboxId"]} " +
+                            "fileId: ${call.parameters["fileId"]}"
+                    )
+                }
+                delete {
+                    call.respond(
+                        HttpStatusCode.OK,
+                        "delete: Not yet implemented. " +
+                            "mailboxId: ${call.parameters["mailboxId"]} " +
+                            "fileId: ${call.parameters["fileId"]}"
+                    )
+                }
+            }
         }
     }
-
-    get("$V/mailboxes") {
-        TODO("Not yet implemented")
+    authenticate(AuthContext.ownerOnly) {
+        get("$V/mailboxes") {
+            call.respond(HttpStatusCode.OK, "get: Not yet implemented")
+        }
     }
-
 }
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 00d9ddcb9b7cfcc8776276eb3406701bc5560a5b..86ac2825d9cb21158ee3afc3ec6ca941b3c07000 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
@@ -1,6 +1,8 @@
 package org.briarproject.mailbox.core.server
 
 import io.ktor.application.install
+import io.ktor.auth.Authentication
+import io.ktor.auth.UserIdPrincipal
 import io.ktor.features.CallLogging
 import io.ktor.server.engine.embeddedServer
 import io.ktor.server.netty.Netty
@@ -9,8 +11,12 @@ import org.slf4j.LoggerFactory.getLogger
 import javax.inject.Inject
 import javax.inject.Singleton
 
+interface WebServerManager : Service
+
 @Singleton
-class WebServerManager @Inject constructor() : Service {
+internal class WebServerManagerImpl @Inject constructor(
+    private val authManager: AuthenticationManager,
+) : WebServerManager {
 
     internal companion object {
         internal const val PORT = 8000
@@ -20,6 +26,32 @@ class WebServerManager @Inject constructor() : Service {
     private val server by lazy {
         embeddedServer(Netty, PORT, watchPaths = emptyList()) {
             install(CallLogging)
+            // TODO validate mailboxId and fileId somewhere
+            install(Authentication) {
+                bearer(AuthContext.ownerOnly) {
+                    realm = "Briar Mailbox Owner"
+                    validate { credentials ->
+                        LOG.error("credentials: $credentials")
+                        if (authManager.canOwnerAccess(credentials)) {
+                            UserIdPrincipal(AuthContext.ownerOnly)
+                        } else null // not authenticated
+                    }
+                }
+                bearer(AuthContext.ownerAndContacts) {
+                    realm = "Briar Mailbox"
+                    validate { credentials ->
+                        LOG.error("credentials: $credentials")
+                        val mailboxId = credentials.mailboxId
+                        // we must have a mailboxId for this AuthContext
+                        if (mailboxId == null) {
+                            LOG.warn("No mailboxId found in request")
+                            null
+                        } else if (authManager.canOwnerOrContactAccess(credentials)) {
+                            UserIdPrincipal(AuthContext.ownerAndContacts)
+                        } else null // not authenticated
+                    }
+                }
+            }
             configureBasicApi()
             configureContactApi()
             configureFilesApi()
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerModule.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerModule.kt
index 37550a3823539a0ab420a0b060c9c0f6a9a6b0aa..be2c92aa2bde8e1495fa490b75971aed979c18b6 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerModule.kt
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerModule.kt
@@ -13,8 +13,10 @@ internal class WebServerModule {
 
     @Provides
     @Singleton
-    fun provideWebServer(lifecycleManager: LifecycleManager): WebServerManager {
-        val webServerManager = WebServerManager()
+    fun provideWebServerManager(
+        lifecycleManager: LifecycleManager,
+        webServerManager: WebServerManagerImpl,
+    ): WebServerManager {
         lifecycleManager.registerService(webServerManager)
         return webServerManager
     }
diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
index 05929a6ef0dd4fdd7bcbf6fb2eaab759ddc7c1bc..88285497c5717b971c7a599355cffc2900beeeeb 100644
--- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
+++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/tor/TorPlugin.java
@@ -1,5 +1,25 @@
 package org.briarproject.mailbox.core.tor;
 
+import static net.freehaven.tor.control.TorControlCommands.HS_ADDRESS;
+import static net.freehaven.tor.control.TorControlCommands.HS_PRIVKEY;
+import static org.briarproject.mailbox.core.tor.TorConstants.CONTROL_PORT;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.ACTIVE;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.DISABLED;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.ENABLING;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.INACTIVE;
+import static org.briarproject.mailbox.core.tor.TorPlugin.State.STARTING_STOPPING;
+import static org.briarproject.mailbox.core.util.IoUtils.copyAndClose;
+import static org.briarproject.mailbox.core.util.IoUtils.tryToClose;
+import static org.briarproject.mailbox.core.util.LogUtils.info;
+import static org.briarproject.mailbox.core.util.LogUtils.logException;
+import static org.briarproject.mailbox.core.util.LogUtils.warn;
+import static org.briarproject.mailbox.core.util.PrivacyUtils.scrubOnion;
+import static org.slf4j.LoggerFactory.getLogger;
+import static java.util.Arrays.asList;
+import static java.util.Collections.singletonList;
+import static java.util.Collections.singletonMap;
+import static java.util.Objects.requireNonNull;
+
 import net.freehaven.tor.control.EventHandler;
 import net.freehaven.tor.control.TorControlConnection;
 
@@ -10,7 +30,7 @@ import org.briarproject.mailbox.core.event.EventListener;
 import org.briarproject.mailbox.core.lifecycle.IoExecutor;
 import org.briarproject.mailbox.core.lifecycle.Service;
 import org.briarproject.mailbox.core.lifecycle.ServiceException;
-import org.briarproject.mailbox.core.server.WebServerManager;
+import org.briarproject.mailbox.core.server.WebServerManagerImpl;
 import org.briarproject.mailbox.core.settings.Settings;
 import org.briarproject.mailbox.core.settings.SettingsManager;
 import org.briarproject.mailbox.core.system.Clock;
@@ -228,7 +248,7 @@ abstract class TorPlugin implements Service, EventHandler, EventListener {
         // Check whether we're online
         updateConnectionStatus(networkManager.getNetworkStatus());
         // Create a hidden service if necessary
-        ioExecutor.execute(() -> publishHiddenService(String.valueOf(WebServerManager.PORT)));
+        ioExecutor.execute(() -> publishHiddenService(String.valueOf(WebServerManagerImpl.PORT)));
     }
 
     private boolean assetsAreUpToDate() {
diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/WebServerIntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/WebServerIntegrationTest.kt
index 753642437efe430a862737084eff97296ec2cf33..904455c82cd6e74d58b9ec02654066958c82b086 100644
--- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/WebServerIntegrationTest.kt
+++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/WebServerIntegrationTest.kt
@@ -9,7 +9,7 @@ import kotlinx.coroutines.runBlocking
 import org.briarproject.mailbox.core.DaggerTestComponent
 import org.briarproject.mailbox.core.TestComponent
 import org.briarproject.mailbox.core.TestModule
-import org.briarproject.mailbox.core.server.WebServerManager.Companion.PORT
+import org.briarproject.mailbox.core.server.WebServerManagerImpl.Companion.PORT
 import org.junit.jupiter.api.AfterAll
 import org.junit.jupiter.api.BeforeAll
 import org.junit.jupiter.api.Test