Building a Code-Based Password Reset Flow End-to-End: Ktor + Jetpack Compose

A six-digit code in your inbox, an enumeration-safe API, optimistic navigation, and a server error message that actually reaches the user — the full password reset flow across a Kotlin backend and an Android client.

Building a Code-Based Password Reset Flow End-to-End: Ktor + Jetpack Compose

A six-digit code in your inbox, an enumeration-safe API, optimistic navigation, and a server error message that actually reaches the user — the full password reset flow across a Kotlin backend and an Android client.


About the code in this article

Everything here comes from a real, open-source project — not a contrived snippet. WC26 Threads is a Threads-style social app I built for the 2026 World Cup: a Jetpack Compose Android client backed by a Ktor + PostgreSQL API. “Forgot password” was the feature that touched the most layers at once, which makes it a great end-to-end walkthrough.

There are two repos, because this is a two-sided feature:

Backend: github.com/AdelAchour/wc26-backend Android: github.com/AdelAchour/wc26-android

The server-side files we’ll walk through:

And on the client:

Snippets are lightly trimmed for readability; the repos have the unabridged versions. Let’s dig in.


The feature that looks trivial and isn’t

“Forgot password” is a checkbox on every product roadmap and a deceptive amount of work under the hood. A single tap has to ripple through a database table, transactional email, two carefully-designed endpoints, navigation, form state, and error handling that’s specific enough to be useful without leaking information.

The flow I built is the classic six-digit-code variant — no reset links, no token-in-URL, no deep-linking:

  1. User taps Forgot password, enters their email.
  2. Server generates a 6-digit code, stores it with a 10-minute expiry, emails it.
  3. User enters the code + a new password.
  4. Server validates the code, updates the password, deletes the code.

A code the user types back into the app is simpler to reason about than a magic link, and it sidesteps a pile of email-client link-rewriting headaches.

The thing I want to convince you of in this article: the most interesting client decisions are reactions to server decisions. You can’t really understand one half without the other. So we’ll do the server first, then watch the client bend itself around the contract the server defined.


Part 1 — The server (Ktor)

Layer 1: a table for short-lived codes

Reset codes don’t belong in the users table — they’re ephemeral, and a user has at most one pending reset at a time. They get their own table:

CREATE TABLE password_resets (
    id          BIGSERIAL       PRIMARY KEY,
    email       CITEXT          NOT NULL,
    code        VARCHAR(6)      NOT NULL,
    expires_at  TIMESTAMPTZ     NOT NULL,
    created_at  TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_password_resets_email ON password_resets (email);

Three deliberate choices:

  • email CITEXT — case-insensitive, matching how emails are stored in users. Adel@… and adel@… resolve to the same row without me having to remember to lowercase everywhere (though I do that too, belt and suspenders).
  • expires_at is a column, not a TTL job. Expiry is enforced at query time. Expired rows are harmless; a periodic sweep can clean them, but nothing breaks if it never runs.
  • No foreign key to users. The reset is keyed by email, and I never want this table’s existence to confirm whether an account exists.

That last point is foreshadowing.

Layer 2: a repository with exactly three operations

Upsert a code, validate a code, delete a code. The “upsert” deletes any previous code first, so requesting a new code immediately kills the old one:

override suspend fun upsertCode(email: String, code: String, expiryMinutes: Int): Unit = dbQuery {
    // Remove any previous codes for this email so only the latest is valid
    PasswordResetTable.deleteWhere { PasswordResetTable.email eq email }

    PasswordResetTable.insert {
        it[PasswordResetTable.email] = email
        it[PasswordResetTable.code] = code
        it[PasswordResetTable.expiresAt] = OffsetDateTime.now(ZoneOffset.UTC)
            .plusMinutes(expiryMinutes.toLong())
    }
    Unit
}

override suspend fun findValidCode(email: String, code: String): String? = dbQuery {
    PasswordResetTable
        .selectAll()
        .where {
            (PasswordResetTable.email eq email) and
            (PasswordResetTable.code eq code) and
            (PasswordResetTable.expiresAt greaterEq OffsetDateTime.now(ZoneOffset.UTC))
        }
        .map { it[PasswordResetTable.code] }
        .singleOrNull()
}

findValidCode folds the expiry check straight into the WHERE clause. An expired code is simply “not found” — there’s no separate “expired” branch anywhere to get wrong.

Layer 3: the service, where the security lives

This is the heart of the feature, and the comments are doing real work:

/**
 * Initiates a password reset flow. If the email exists, generates a
 * 6-digit numeric code, stores it with an expiry, and sends it via email.
 *
 * Always succeeds from the caller's perspective to prevent email enumeration.
 */
suspend fun forgotPassword(email: String) {
    val normalizedEmail = email.trim().lowercase()

    // Only proceed if the user actually exists — but always return success
    val userExists = userRepository.emailExists(normalizedEmail)
    if (!userExists) return

    val code = generateResetCode()
    passwordResetRepository.upsertCode(normalizedEmail, code, CODE_EXPIRY_MINUTES)
    emailService.sendPasswordResetEmailAsync(normalizedEmail, code, CODE_EXPIRY_MINUTES)
}

suspend fun resetPassword(email: String, code: String, newPassword: String): ResetPasswordResult {
    val normalizedEmail = email.trim().lowercase()

    if (newPassword.length < MIN_PASSWORD_LENGTH) return ResetPasswordResult.PasswordTooShort

    val validCode = passwordResetRepository.findValidCode(normalizedEmail, code)
        ?: return ResetPasswordResult.InvalidOrExpiredCode

    val newHash = passwordHasher.hash(newPassword)
    val updated = userRepository.updatePasswordHash(normalizedEmail, newHash)
    if (!updated) return ResetPasswordResult.InvalidOrExpiredCode

    // Code used successfully — delete it so it can't be reused
    passwordResetRepository.deleteByEmail(normalizedEmail)

    return ResetPasswordResult.Success
}

Three things worth pausing on:

1. Enumeration protection. forgotPassword returns Unit whether or not the email exists. If it doesn’t, the function quietly returns and the route still responds 200 OK. An attacker probing the endpoint with a list of emails learns nothing about which ones are real accounts. Remember the table with no foreign key — this is why.

2. Single-use codes. A successful reset deletes the code. There’s no window where a leaked code can be replayed after the password has already changed.

3. The code is dumb on purpose. (100_000..999_999).random() — a plain 6-digit number. It’s not cryptographically strong, and it doesn’t need to be: it lives 10 minutes, it’s single-use, and a new request invalidates the old one. That triad — short expiry + single-use + invalidate-on-reissue — is what makes a short code safe. (Per-email rate limiting on forgot-password is the next lever if you want to harden further.)

Results are modelled as a sealed interface so the route layer handles every case exhaustively:

sealed interface ResetPasswordResult {
    data object Success : ResetPasswordResult
    data object PasswordTooShort : ResetPasswordResult
    data object InvalidOrExpiredCode : ResetPasswordResult
}

The routes

Thin, as routes should be. The only subtlety is forgot-password, which ignores the service result entirely and always responds the same way:

post("forgot-password") {
    val request = call.receive<ForgotPasswordRequest>()
    service.forgotPassword(request.email)
    // Always return 200 regardless of whether the email exists
    call.respond(HttpStatusCode.OK, mapOf("message" to "If an account with this email exists, a reset code has been sent."))
}

post("reset-password") {
    val request = call.receive<ResetPasswordRequest>()

    when (service.resetPassword(request.email, request.code, request.newPassword)) {
        ResetPasswordResult.Success ->
            call.respond(HttpStatusCode.OK, mapOf("message" to "Password reset successful."))
        ResetPasswordResult.PasswordTooShort ->
            call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Password must be at least 8 characters"))
        ResetPasswordResult.InvalidOrExpiredCode ->
            call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Invalid or expired reset code"))
    }
}

Note the response shape: success bodies carry a message field, error bodies carry an error field. That contract is about to matter a lot on the client.

The email service: async, with timeouts, failures swallowed

Jakarta Mail does blocking network I/O, and Ktor’s handlers run on coroutines. You do not want to block a request thread for the duration of an SMTP handshake. So sending is fire-and-forget on a dedicated IO scope:

class EmailService(private val config: SmtpConfig) {

    private val logger = LoggerFactory.getLogger(EmailService::class.java)
    private val emailScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    private val session: Session by lazy {
        val props = Properties().apply {
            put("mail.smtp.auth", "true")
            put("mail.smtp.starttls.enable", config.startTls.toString())
            put("mail.smtp.host", config.host)
            put("mail.smtp.port", config.port.toString())
            // Timeouts to avoid indefinite hangs on network issues
            put("mail.smtp.connectiontimeout", TIMEOUT_MS)
            put("mail.smtp.timeout", TIMEOUT_MS)
            put("mail.smtp.writetimeout", TIMEOUT_MS)
        }
        Session.getInstance(props, object : Authenticator() {
            override fun getPasswordAuthentication() =
                PasswordAuthentication(config.username, config.password)
        })
    }

    fun sendPasswordResetEmailAsync(toEmail: String, code: String, expiryMinutes: Int) {
        emailScope.launch {
            try {
                val htmlBody = passwordResetTemplate
                    .replace("{{RESET_CODE}}", code)
                    .replace("{{CODE_EXPIRY_MINUTES}}", expiryMinutes.toString())
                // ... a plain-text fallback is built too ...
                send(to = toEmail, subject = "Reset your password — WC26", plainText = plainBody, html = htmlBody)
                logger.info("[Email] Password reset email sent to {}", toEmail)
            } catch (e: Exception) {
                logger.error("[Email] Failed to send password reset email to {}", toEmail, e)
            }
        }
    }
}

Why each piece is there:

  • SupervisorJob — one failed send doesn’t cancel the scope and take down future sends.
  • Explicit timeouts. Without connectiontimeout / timeout / writetimeout, a flaky SMTP server can pin a thread indefinitely. Ten seconds is plenty.
  • Failures are logged, never thrown. The API already told the user “if an account exists, we sent a code.” Surfacing a delivery failure back to the request would both break the enumeration guarantee and give the user nothing actionable.
  • The HTML template is loaded once and cached (by lazy), then rendered with dumb {{PLACEHOLDER}} string replacement. No templating engine for two substitutions.

The email itself is a multipart/alternative MIME message — a plain-text part and an HTML part (old-school table layout, the only thing email clients reliably render, with a big monospace code box). SMTP settings come from application.yaml with env-var overrides, wired through the production docker-compose, so nothing secret is committed.

That’s the server. Two endpoints, one table, one async email service — and most of the code exists to make sure it fails safely.


Part 2 — The client (Jetpack Compose)

The Android side is two screens — Forgot Password (enter email) and Reset Password (enter code + new password) — plus the plumbing to talk to the two new endpoints. Standard Compose stack: a Retrofit API, a repository returning DataResult, a ViewModel exposing StateFlow<UiState>, and a stateless screen.

But the two most interesting client decisions are both reactions to the server. Let’s take them in order.

Reaction 1: the client has no “email not found” branch

Look at the Forgot Password ViewModel’s submit:

fun submit() {
    val state = _uiState.value

    val emailError = AuthValidation.emailError(state.email)
    if (emailError != null) {
        _uiState.update { it.copy(emailError = emailError) }
        return
    }

    viewModelScope.launch {
        _uiState.update { it.copy(loading = true, formError = null) }
        when (val result = authRepository.forgotPassword(state.email)) {
            is DataResult.Success ->
                _uiState.update { it.copy(loading = false, success = true) }
            is DataResult.Error ->
                _uiState.update { it.copy(loading = false, formError = result.error) }
        }
    }
}

Notice what isn’t here: any handling for “that email doesn’t exist.” Because the server always returns 200, the client treats a successful response as “move to the next screen,” full stop. The enumeration guarantee on the backend means the client literally has no not-found branch to write.

That’s the theme of the whole post in miniature: a server-side security decision deleted client code. The best kind of security is the kind that makes the rest of the system simpler.

The UiState is the pattern I use for every form in the app — inline per-field validation errors, a separate form-level error, and a success flag the screen observes to navigate:

data class ForgotPasswordUiState(
    val email: String = "",
    val emailError: ValidationError? = null,   // inline, per-field
    val formError: AppError? = null,            // submission failure
    val loading: Boolean = false,
    val success: Boolean = false,               // screen observes this to navigate
) {
    val canSubmit: Boolean get() = email.isNotBlank() && !loading
}

Reaction 2: surfacing the server’s exact error string

Here’s the second place the server’s design forces a client decision. For most calls, mapping an HTTP status to a generic error is fine — a 409 on registration means “already taken,” done. But reset-password’s failure ("Invalid or expired reset code") is a 400, and “Bad request” is a useless thing to show a user who fat-fingered a code.

The server already returns a human-readable string in that {"error": …} body. The client just has to bother reading it. So I added a parallel apiCall variant that cracks open the error body:

suspend fun <T> apiCallWithServerMessage(
    json: Json,
    block: suspend () -> T,
): DataResult<T> =
    try {
        DataResult.Success(data = block())
    } catch (e: HttpException) {
        val serverMsg = try {
            e.response()?.errorBody()?.string()?.let { body ->
                json.decodeFromString<ErrorResponse>(body).error
            }
        } catch (_: Exception) { null }
        DataResult.Error(
            error = serverMsg?.let { AppError.ServerMessage(it) } ?: httpError(e.code()),
            cause = e,
        )
    } catch (e: IOException) {
        DataResult.Error(error = AppError.Network, cause = e)
    } catch (e: Exception) {
        DataResult.Error(error = AppError.Unknown, cause = e)
    }

If the body parses, we get an AppError.ServerMessage carrying the exact string; otherwise we fall back to the normal status-code mapping. That required promoting AppError from a plain enum to a sealed interface, so one case could carry a payload:

sealed interface AppError {
    data object Network : AppError
    data object Unauthorized : AppError
    // ...
    /** Carries the raw error message from the server response body. */
    data class ServerMessage(val message: String) : AppError
}

This is the kind of change that’s trivial up front and annoying to retrofit later. The enum → sealed-interface migration touched a few unrelated files (paging sources, error-text mappers), but it’s the right shape: most errors are singletons, one error carries data.

With the helper in place, the repository methods are three lines each:

override suspend fun forgotPassword(email: String): DataResult<String> =
    apiCallWithServerMessage(json) {
        authApi.forgotPassword(ForgotPasswordRequest(email))
    }.map { it.message }

override suspend fun resetPassword(email: String, code: String, newPassword: String): DataResult<String> =
    apiCallWithServerMessage(json) {
        authApi.resetPassword(ResetPasswordRequest(email, code, newPassword))
    }.map { it.message }

Threading the email between screens

The Reset Password screen needs the email the user typed on the previous screen — but you don’t want to make them type it twice. Type-safe Compose navigation carries it as a route argument, and the ViewModel reads it back via SavedStateHandle:

@Serializable data object ForgotPassword
@Serializable data class ResetPassword(val email: String)
private val args = savedStateHandle.toRoute<Destinations.ResetPassword>()
private val _uiState = MutableStateFlow(ResetPasswordUiState(email = args.email))

The nav wiring then reads like the flow itself — Login → Forgot Password → (code sent) → Reset Password → (success) → back to Login, with the reset screen popped off the back stack so the user can’t navigate back into a half-finished reset:

composable<Destinations.ForgotPassword> {
    ForgotPasswordScreen(
        onCodeSent = { email -> navController.navigate(Destinations.ResetPassword(email)) },
        onBackToLogin = { navController.popBackStack() },
    )
}
composable<Destinations.ResetPassword> {
    ResetPasswordScreen(
        onPasswordReset = {
            navController.navigate(Destinations.Login) {
                popUpTo(Destinations.Login) { inclusive = true }
            }
        },
        onBack = { navController.popBackStack() },
    )
}

Both new screens also register as part of the “auth flow,” so the bottom navigation bar stays hidden while the user is resetting.


The two halves, side by side

The whole point of doing this end-to-end is that the decisions interlock. Each server choice has a direct client consequence:

Server decisionClient consequence
forgot-password always returns 200 (enumeration-safe)No “email not found” branch — success means “navigate forward”
Errors return a human {"error": …} stringA whole apiCallWithServerMessage path + AppError.ServerMessage to surface it
Codes expire in 10 min, single-use, reissue-invalidatesReset screen’s retry UX: a wrong/old code is just “Invalid or expired code”
Email keyed by address, threaded through the flowEmail passed as a nav arg so the user never re-types it

Read either column alone and it looks like an arbitrary API spec. Read them together and they’re two halves of the same design.


Takeaways

If you remember one thing: a clean client/server contract is worth more than clever code on either side. A few principles that fell out of building this:

  1. Security decisions belong on the server, and they ripple outward. Enumeration protection wasn’t a client feature — but it removed client code. Security that simplifies the rest of the system is the best kind.
  2. Design the error contract, not just the success contract. Returning a readable error string for the one case the user can act on (a bad code) is what made the client UX good. Everything else stays generic.
  3. Async, fire-and-forget email — with timeouts and swallowed failures — is the right default for transactional mail behind a request. The user already got their response; delivery is the server’s problem now.
  4. Model results as sealed types on both ends. Exhaustive when over ResetPasswordResult on the server and DataResult / AppError on the client means the compiler keeps you honest about every branch.

Two endpoints, one table, two screens. Most of the work was in the decisions, not the code.


All code in this article is from WC26 Threads, my open-source World Cup app. Backend: github.com/AdelAchour/wc26-backend · Android: github.com/AdelAchour/wc26-android. Files referenced: AuthService.kt, EmailService.kt, AuthRoutes.kt, PasswordResetRepositoryImpl.kt, ApiCall.kt, AuthRepositoryImpl.kt, ForgotPasswordViewModel.kt, ResetPasswordViewModel.kt, WC26NavHost.kt.