Supabase Auth with KMP Client

Detailed instructions on how to set up Supabase authentication in a Kotlin Compose Multiplatform project

0) What we’re building (high-level)

  • UI: Login + Register screens, both driven by a single AuthViewModel.
  • Domain contracts (core/): AuthRepository, RemoteAuthDataSource, LocalAuthDataSource, models like User & AuthState.
  • Data impls (features/auth/data/): Supabase calls + (optional) persistent session store.
  • DI: Koin wires core contracts ↔ feature impls + a single Supabase client.
  • Session: auto-refresh + restore on launch; UI reacts to a StateFlow<AuthState>.

Why this structure?

  • You can swap Supabase later without touching UI.
  • Tests can mock AuthRepository.
  • KMP targets stay happy because core has no framework deps.

1) Set up Gradle like it’s 2025

1.1 Add Supabase (BOM) + modules + Ktor engines

supabase-kt is the Kotlin MPP client; you add it via a BOM and pick modules (auth-kt, etc.). Ktor engines are required per target; for example: cio for JVM/desktop, darwin for iOS, js for web. Also enable kotlinx-serialization (default serializer). Minimum Android SDK 26. (Supabase)

// build.gradle.kts (root or shared module as appropriate)
plugins {
  kotlin("multiplatform") version "<your version>"
  kotlin("plugin.serialization") version "<same kotlin version>" // serializer plugin
}

kotlin {
  androidTarget() // if targeting Android
  jvm()           // desktop
  ios()           // iOS
  js(IR)          // web/wasm if you want

  sourceSets {
    val commonMain by getting {
      dependencies {
        implementation(platform("io.github.jan-tennert.supabase:bom:<latest>"))
        implementation("io.github.jan-tennert.supabase:auth-kt")
        // add postgrest-kt / storage-kt / realtime-kt if needed
      }
    }
    val jvmMain by getting {
      dependencies { implementation("io.ktor:ktor-client-cio:<ktor3+>") }
    }
    val androidMain by getting {
      // often dependsOn(jvmMain), or use okhttp engine if you prefer
    }
    val iosMain by getting {
      dependencies { implementation("io.ktor:ktor-client-darwin:<ktor3+>") }
    }
    val jsMain by getting {
      dependencies { implementation("io.ktor:ktor-client-js:<ktor3+>") }
    }
  }
}
  • Ktor 3.x is required for supabase-kt 3.0+. (Supabase)
  • Supabase keeps the “Installing” page updated with exact coords; check that whenever you bump versions. (Supabase)

1.2 Optional: key-value storage for your own session cache

Add Multiplatform Settings if you want to persist something yourself (you can—Supabase can also manage session for you, details later). (GitHub, Maven Central)

commonMain {
  dependencies { implementation("com.russhwolf:multiplatform-settings:<latest>") }
}

1.3 Compose + Koin (multiplatform)

Use the Koin Compose Multiplatform artifacts; you’ll inject both singletons and viewmodels with koinInject() / koinViewModel(). (insert-koin.io)


2) Project layout you can actually live with

shared/
└─ src/commonMain/kotlin/com/your/app
   ├─ core/
   │  ├─ contracts/auth/
   │  │  ├─ AuthRepository.kt
   │  │  ├─ RemoteAuthDataSource.kt
   │  │  ├─ LocalAuthDataSource.kt
   │  │  └─ Models.kt  // User, AuthState, etc.
   │  └─ di/
   │     └─ SupabaseModule.kt
   └─ features/
      └─ auth/
         ├─ data/
         │  ├─ RemoteAuthDataSourceImpl.kt
         │  ├─ LocalAuthDataSourceImpl.kt   // optional (see §5)
         │  └─ AuthRepositoryImpl.kt
         ├─ presentation/
         │  ├─ AuthViewModel.kt
         │  └─ screens/ LoginScreen.kt, RegisterScreen.kt
         └─ di/ AuthModule.kt

Why: core = API surface, features = implementation. Core doesn’t import Supabase or Compose; it stays stable.


3) Contracts (core/contracts/auth)

Keep them tiny and boring.

// Models.kt
data class User(val id: String, val email: String?)
sealed interface AuthState {
  data object Loading : AuthState
  data object LoggedOut : AuthState
  data class Authenticated(val user: User) : AuthState
  data class Error(val message: String) : AuthState
}

// Contracts
interface RemoteAuthDataSource {
  suspend fun signIn(email: String, password: String): User
  suspend fun signUp(email: String, password: String): User?
  suspend fun signOut()
  suspend fun currentUserOrNull(): User?
}

interface LocalAuthDataSource {
  fun saveLastEmail(email: String?)
  fun readLastEmail(): String?
  // If you decide to DIY session persistence, add methods—but see §5 before you do.
}

interface AuthRepository {
  val state: StateFlow<AuthState>
  suspend fun login(email: String, password: String)
  suspend fun register(email: String, password: String)
  suspend fun logout()
  suspend fun restore() // call on app start
}

Why: the UI holds a StateFlow<AuthState>, calls three verbs, and never sees Supabase types.


4) DI: Supabase + modules

4.1 Create the Supabase client once

// core/di/SupabaseModule.kt
val supabaseModule = module {
  single {
    createSupabaseClient(
      supabaseUrl = BuildKonfig.SUPABASE_URL,       // however you inject config
      supabaseKey = BuildKonfig.SUPABASE_ANON_KEY   // publishable/anon only
    ) {
      install(Auth) {
        // sensible defaults: auto-refresh & auto-load from storage are true
        // alwaysAutoRefresh = true
        // autoLoadFromStorage = true
      }
      // install(Postgrest) { ... } if you need DB later
    }
  }
}
  • Why anon key? Service role keys bypass RLS and must never be in clients. Use anon on client; enforce access with RLS. (Supabase)
  • Client creation & plugin install are from Supabase’s Kotlin docs. (Supabase)

4.2 Feature bindings

// features/auth/di/AuthModule.kt
val authModule = module {
  single<RemoteAuthDataSource> { RemoteAuthDataSourceImpl(get()) }
  single<LocalAuthDataSource> { LocalAuthDataSourceImpl(get()) } // optional
  single<AuthRepository> { AuthRepositoryImpl(get(), get()) }
  factory { AuthViewModel(get()) }
}

Start Koin in your platform entry before rendering Compose, or via KoinApplication in the root Composable. (insert-koin.io)


5) Sessions: who should store them?

Good news: supabase-kt already handles session storage & auto-refresh by default (autoLoadFromStorage = true, alwaysAutoRefresh = true), and exposes:

  • auth.currentSessionOrNull() to read,
  • auth.sessionStatus: Flow<SessionStatus> to react to changes. (Supabase Docs, Supabase)

So you don’t need to reinvent token storage unless you want custom behavior or analytics. If you do want custom persistence (e.g., syncing across profiles), you can also import a session manually via auth.importSession(session) or tweak config; but for most apps, default handling is perfect. (Supabase)

I’ll show the repo using the built-in session handling. If you choose to DIY later, you can adapt your LocalAuthDataSource to save/restore a serialized session and call importSession.


6) Remote data source (features/auth/data)

Use the official APIs:

class RemoteAuthDataSourceImpl(
  private val supabase: SupabaseClient
) : RemoteAuthDataSource {

  override suspend fun signIn(email: String, password: String): User {
    supabase.auth.signInWith(Email) {
      this.email = email
      this.password = password
    }
    val u = supabase.auth.currentSessionOrNull()?.user
    return User(u!!.id, u.email)
  }

  override suspend fun signUp(email: String, password: String): User? {
    // By default, email confirmation may be required; return may be null until confirm.
    supabase.auth.signUpWith(Email) {
      this.email = email
      this.password = password
    }
    val u = supabase.auth.currentSessionOrNull()?.user // might be null if confirm-email is ON
    return u?.let { User(it.id, it.email) }
  }

  override suspend fun signOut() {
    supabase.auth.signOut()
  }

  override suspend fun currentUserOrNull(): User? =
    supabase.auth.currentSessionOrNull()?.user?.let { User(it.id, it.email) }
}
  • signInWith(Email) and signUpWith(Email) forms are straight from the reference. (Supabase)
  • currentSessionOrNull() is the supported way to read session quickly. (Supabase)

Heads-up: If “Confirm email” is enabled in your project, sign-up won’t yield a session until the user clicks the link (or OTP). That’s expected. (Supabase Docs)


7) (Optional) Local data source

Keep it tiny. I like to only store the last used email for UX.

class LocalAuthDataSourceImpl(
  private val settings: Settings // from Multiplatform Settings
) : LocalAuthDataSource {
  override fun saveLastEmail(email: String?) = settings.putString("last_email", email ?: "")
  override fun readLastEmail(): String? = settings.getStringOrNull("last_email")
}

Why not store tokens here? Because the Supabase Auth plugin already persists and refreshes sessions (safely) for you. You can revisit if you need custom behavior. (Supabase)


8) Repository (single source of truth)

It glues everything together and exposes a flow the UI can read.

class AuthRepositoryImpl(
  private val remote: RemoteAuthDataSource,
  private val local: LocalAuthDataSource
) : AuthRepository {

  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
  private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
  override val state: StateFlow<AuthState> = _state

  override suspend fun restore() {
    _state.value = AuthState.Loading
    val user = runCatching { remote.currentUserOrNull() }.getOrNull()
    _state.value = if (user != null) AuthState.Authenticated(user) else AuthState.LoggedOut
  }

  override suspend fun login(email: String, password: String) {
    _state.value = AuthState.Loading
    val result = runCatching { remote.signIn(email, password) }
    _state.value = result.fold(
      onSuccess = { user ->
        local.saveLastEmail(email)
        AuthState.Authenticated(user)
      },
      onFailure = { AuthState.Error(it.message ?: "Login failed") }
    )
  }

  override suspend fun register(email: String, password: String) {
    _state.value = AuthState.Loading
    val result = runCatching { remote.signUp(email, password) }
    _state.value = result.fold(
      onSuccess = { userOrNull ->
        // If email confirmation is ON, userOrNull could be null.
        if (userOrNull != null) AuthState.Authenticated(userOrNull)
        else AuthState.Error("Check your inbox to confirm your email.")
      },
      onFailure = { AuthState.Error(it.message ?: "Sign up failed") }
    )
  }

  override suspend fun logout() {
    runCatching { remote.signOut() }
    _state.value = AuthState.LoggedOut
  }

  init {
    // Bonus: react to Supabase session changes if you want live sync:
    // supabase.auth.sessionStatus is a Flow<SessionStatus> — plumb it here if desired.
    // (You can inject SupabaseClient if you want this; otherwise restore() at app start is enough)
  }
}

Why: Repository owns transitions (Loading → Authed/LoggedOut/Error). Screens stay dumb.

If you want immediate reactivity to token refresh or sign-out, collect supabase.auth.sessionStatus in init { } and translate to AuthState. (Supabase)


9) ViewModel (lean)

One VM for both screens. State in, intents out.

class AuthViewModel(
  private val repo: AuthRepository
) : KmpViewModel() {
  val ui: StateFlow<AuthState> = repo.state

  fun restore() = scope.launch { repo.restore() }
  fun login(email: String, pw: String) = scope.launch { repo.login(email, pw) }
  fun register(email: String, pw: String) = scope.launch { repo.register(email, pw) }
  fun logout() = scope.launch { repo.logout() }
}

Why: Auth is one state machine. Don’t duplicate logic across two VMs unless register becomes a multi-step saga.


10) Screens

Keep form fields inside Composables; pass values into VM methods. Passwords shouldn’t live in long-lived flows.

@Composable
fun LoginScreen(
  vm: AuthViewModel = koinViewModel(),                  // Koin Compose API
  onGoRegister: () -> Unit
) {
  val state by vm.ui.collectAsState()
  var email by remember { mutableStateOf("") }
  var pw by remember { mutableStateOf("") }

  LaunchedEffect(Unit) { vm.restore() }

  Column {
    if (state is AuthState.Error) Text("⚠ ${(state as AuthState.Error).message}")
    OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("Email") })
    OutlinedTextField(value = pw, onValueChange = { pw = it }, label = { Text("Password") })
    Button(
      enabled = state !is AuthState.Loading,
      onClick = { vm.login(email, pw) }
    ) { Text(if (state is AuthState.Loading) "…" else "Sign in") }

    TextButton(onClick = onGoRegister) { Text("Create account") }
  }
}

Koin’s koinViewModel()/koinInject() are the right calls for Compose Multiplatform. (insert-koin.io)


11) Deep links / OTP / OAuth (optional but good to know)

If you use magic link, OTP, or OAuth, the Kotlin client supports platform-specific flows (deeplinks on Android/iOS; HTTP callback server on desktop; origin on JS). You configure install(Auth) { host = "..."; scheme = "..." } and call supabase.handleDeeplinks(...) on startup. For most email/password flows you won’t need this, but it’s here when you expand. (Supabase)


12) Bootstrapping Koin in your entry

fun startDI() = startKoin {
  modules(
    supabaseModule,   // core
    authModule        // feature
  )
}

// Desktop/JVM main() or Android Application.onCreate() etc.

Or wrap your App() with KoinApplication { modules(...) } if you can’t start in a platform entry. (insert-koin.io)


13) Security & config checklist (trust me, do this)

  • Use only the anon/publishable key in clients. Never ship service_role. Ever. (Supabase)
  • Enable RLS and write policies. The anon key is safe only if policies are correct. (Supabase)
  • Ktor engines per target and serialization plugin enabled (kotlinx). (Supabase)
  • Android minSdk 26 (or add desugaring if you must go lower). (Supabase)
  • Supabase APIs you’ll call are stable and documented: signInWith(Email), signUpWith(Email), currentSessionOrNull(), signOut(), sessionStatus flow. (Supabase)

14) Flow of control (runtime)

  1. App starts → Koin builds Supabase + repo.
  2. AuthViewModel.restore() runs → reads session → emits Authenticated or LoggedOut.
  3. User logs in → Supabase sets session → repo emits Authenticated.
  4. Supabase auto-refresh handles expiry; sessionStatus can keep you in sync. (Supabase)

15) Common gotchas (and fixes)

  • “I signed up but currentSessionOrNull() is null” → email confirmation is ON; user must verify. You can customize redirect URLs or turn it off. (Supabase Docs)
  • “Refresh/session restore feels flaky” → ensure you’re on a current supabase-kt (3.x) and matching Ktor 3.x; don’t fight the built-in session manager unless you have a reason. Check the releases for updates. (GitHub)
  • “Compose can’t find my VM” → use koinViewModel() and make sure the module declares viewModel { ... } (or factory { AuthViewModel(get()) } + your own ViewModel base). (insert-koin.io)

TL;DR build order

  1. Gradle: add Supabase + Ktor engines + kotlinx-serialization. (Supabase)
  2. Core contracts: AuthRepository, data sources, models.
  3. Supabase client: install(Auth); anon key only. (Supabase)
  4. Feature impls: RemoteAuthDataSourceImpl uses signInWith(Email)/signUpWith(Email); repo owns StateFlow. (Supabase)
  5. DI: wire modules; VM: thin; UI: dumb. (insert-koin.io)
  6. Optional: deep links/OAuth/OTP when you go fancy. (Supabase)