---
title: 'Authentication Flows'
source: 'https://academia.sh/en/courses/platform-services-and-integrations/authentication-flows'
course: 'Platform Services and Integrations'
language: en
updated: '2026-10-21T05:02:41+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Distinguishes what biometric authentication provides from what account-based sign-in provides'
    - 'Explains the roles of the access token and the refresh token'
    - 'Measures two concurrent requests starting the same refresh twice as duplicated'
    - 'Shows that single flight drops the later refresh, and that this is lost rather than superseded'
---

# Authentication Flows

Biometric authentication opens a gate on the device, while account-based sign-in gives the server an access token and a refresh token. The lesson measures how many copies reach the server when concurrent requests start the same refresh twice, and how single flight changes that.

In the previous lesson, the request could go out to the network. Before accepting a request, though, the server asks who sent it. This lesson deals with the two faces of that question: biometric authentication, which recognizes the user on the device, and account-based sign-in, which recognizes the user on the server. Where the two meet is tokens, and the problem arises there, when a token expires. The question is the course's question: **how many copies of this sent event reached the receiver?** The event is the refresh request, and the receiver is the server.

```kotlin
// Arrival.kt — classifies how many copies of a sent event reached the receiver
class Signal(val key: String, val seq: Int)

class Arrival(val label: String, val sent: Signal, received: List<Signal>) {
    val copies: Int = received.count { it.seq == sent.seq }
    val verdict: String = when {
        copies > 1 -> "duplicated"
        copies == 1 -> "once"
        received.any { it.key == sent.key && it.seq > sent.seq } -> "superseded"
        else -> "lost"
    }

    fun line() {
        println("$label: key=${sent.key} | copies=$copies -> $verdict")
    }
}

fun countArrivals(arrivals: List<Arrival>) {
    for (name in listOf("once", "duplicated", "superseded", "lost")) {
        println("$name: ${arrivals.count { it.verdict == name }}")
    }
}
```

## Two Authentications, Two Tokens

Biometric authentication (`BiometricPrompt`) is local: a fingerprint or a face shows that the user is authorized to unlock the device, and it tells the server nothing. The app uses this result as a gate; for example, it reads the refresh token only after the authentication.

```kotlin
// Platform code: BiometricPrompt
val prompt = BiometricPrompt(
    activity,
    ContextCompat.getMainExecutor(activity),
    object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            startRefresh()
        }
    },
)
val info = BiometricPrompt.PromptInfo.Builder()
    .setTitle("Verify your identity")
    .setNegativeButtonText("Cancel")
    .build()
prompt.authenticate(info)
```

Account-based sign-in, on the other hand, happens with the server and returns two tokens. The **access token** is short-lived and is attached to every request. The **refresh token** is long-lived and is used only to obtain a new access token. When the access token expires, the server answers the request with unauthorized; the client sends the refresh token to the server and receives a new access token.

```kotlin
// Platform code: Authenticator
class TokenAuthenticator(private val store: TokenStore) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        val fresh = store.refresh() ?: return null
        return response.request.newBuilder()
            .header("Authorization", "Bearer $fresh")
            .build()
    }
}
```

The two pieces are platform code, and here we make no claim about output, duration, or counts. The model builds the platform's rule; the model is not the platform itself. In the model, a call carries the access token it holds and the refresh event it will send if it gets an unauthorized response. Every call without a valid token sends a refresh.

```kotlin
// Flow.kt — every call with an invalid token sends its own refresh event
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

class Call(val name: String, val token: String, val refresh: Signal)

fun refreshAll(valid: String, calls: List<Call>): List<Signal> =
    calls.filter { it.token != valid }.map { it.refresh }

fun singleFlight(valid: String, calls: List<Call>): List<Signal> = runBlocking {
    val mutex = Mutex()
    val inFlight = mutableSetOf<String>()
    val started = mutableListOf<Signal>()
    calls.filter { it.token != valid }.map { call ->
        async {
            mutex.withLock {
                if (inFlight.add(call.refresh.key)) started.add(call.refresh)
            }
        }
    }.awaitAll()
    started
}
```

`refreshAll` is unprotected: every unauthorized call sends a refresh. `singleFlight`, on the other hand, drops the second refresh of the same key without waiting while one refresh is in progress; the check and the record are done under a `Mutex`, so that two calls do not both see "not yet" at the same time.

```kotlin
// Probe.kt — counts the list that reached the server as the receiver for the refreshes that were sent
fun probe(sent: List<Signal>, received: List<Signal>): List<Arrival> =
    sent.mapIndexed { i, s -> Arrival("event-${i + 1}", s, received) }
```

## Why the Race Arises

The refresh race arises because three steps leave gaps between them: the call receives an unauthorized response, reads the refresh token, and sends the refresh request. These steps look sequential, but when several requests of a screen set out at the same time, each request has its own order. When the first request receives the unauthorized response, a refresh starts; if the second request receives the unauthorized response before the new token has arrived, it starts a refresh too. No one knows that the other has started.

The model of the unprotected flow is the `refreshAll` function, and it builds this race in a single line: every call without a valid token sends its own refresh. The calls do not see each other.

The job of single flight is to make the calls show themselves to each other. It keeps a shared record: if a refresh is in progress, its key is written to the record, and if a later call sees the key in the record, it does not send its own refresh and waits for the result. What is critical here is that the question "is it in the record?" and the operation "write to the record" are not split. If they are separate steps, two calls both see "absent" in between and both send. The `Mutex` in the model prevents this split: once a call takes the lock, the check and the record are done together, and the others wait in line.

The lock has a cost too: the calls in line wait until the lock is released. In the model the wait is very short and does not affect the measurement; in a real app, if a network request is made inside the lock, the wait grows, which is why the lock is kept to the check and the record, and the network request is made outside the lock.

## When Every Call Has Its Own Token

Two of three calls have an old access token and belong to two separate sessions. The third has a valid token and sends no refresh.

```kotlin
// Main.kt — the refreshes of separate sessions arrive as one copy each
fun main() {
    val a = Signal("refresh.a", 1)
    val b = Signal("refresh.b", 2)
    val c = Signal("refresh.c", 3)
    val calls = listOf(Call("screen-1", "old", a), Call("screen-2", "old", b), Call("screen-3", "new", c))
    val received = refreshAll("new", calls)
    val arrivals = probe(listOf(a, b), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=refresh.a | copies=1 -> once
event-2: key=refresh.b | copies=1 -> once
once: 2
duplicated: 0
superseded: 0
lost: 0
```

Both of the two refreshes that were sent arrived as one copy each. The call with a valid token sends no refresh, so it is not an event in the measurement.

## Two Concurrent Requests

When a screen opens, several requests go out at the same time. If the access token is old, all of these requests receive an unauthorized response and all of them send the refresh token to the server. Because the refresh token of the same session is the same, the event that reaches the server is the same event.

```kotlin
// Main.kt — concurrent calls of the same session send the refresh more than once
fun main() {
    val a = Signal("refresh.a", 1)
    val b = Signal("refresh.b", 2)
    val c = Signal("refresh.c", 3)
    val calls = listOf(
        Call("screen-1", "old", a),
        Call("screen-2", "old", a),
        Call("screen-3", "old", b),
        Call("screen-4", "old", c),
        Call("screen-5", "old", c),
        Call("screen-6", "old", c),
    )
    val received = refreshAll("new", calls)
    val arrivals = probe(listOf(a, b, c), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=refresh.a | copies=2 -> duplicated
event-2: key=refresh.b | copies=1 -> once
event-3: key=refresh.c | copies=3 -> duplicated
once: 1
duplicated: 2
superseded: 0
lost: 0
```

This lesson's delivery: duplicated. A single session may have sent the same refresh twice and three times; the rule reads the copy count. If the server renews the refresh token on every use, this excess can cause problems, but the measurement does not show that; the rule says only how many copies arrived.

What happens if the same six calls run with single flight? Of the six calls, three carry different keys and three carry repeats; single flight leaves only one of each key.

```kotlin
// Main.kt — single flight reduces the copies of the same event to one copy
fun main() {
    val a = Signal("refresh.a", 1)
    val b = Signal("refresh.b", 2)
    val c = Signal("refresh.c", 3)
    val calls = listOf(
        Call("screen-1", "old", a),
        Call("screen-2", "old", a),
        Call("screen-3", "old", b),
        Call("screen-4", "old", c),
        Call("screen-5", "old", c),
        Call("screen-6", "old", c),
    )
    val received = singleFlight("new", calls)
    val arrivals = probe(listOf(a, b, c), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=refresh.a | copies=1 -> once
event-2: key=refresh.b | copies=1 -> once
event-3: key=refresh.c | copies=1 -> once
once: 3
duplicated: 0
superseded: 0
lost: 0
```

The result that was duplicated 2 in the unprotected flow became once 3 with single flight. This is the purpose of single flight: to reduce the copies of the same event to one copy. That it drops different events is a side effect, seen in the next section.

**SE1.** The model assumes that the calls of the same session carry the same refresh event. On the platform, a refresh token being renewed on every use can lead the second copy to a different result on the server; the model does not build this.

**SE2.** The model leaves biometric authentication out of the measurement. The authentication opens the gate; the refresh flow behind the gate is what is measured.

## The Bounding Measurement

There are four calls. The first two carry a refresh with the same key but are different events: the second was sent later, with a greater `seq`. The last two share the same event. First, the unprotected flow.

```kotlin
// Main.kt — in the unprotected flow, the calls of the same session send separate events
fun main() {
    val a1 = Signal("refresh.a", 1)
    val a2 = Signal("refresh.a", 2)
    val b = Signal("refresh.b", 3)
    val calls = listOf(
        Call("screen-1", "old", a1),
        Call("screen-2", "old", a2),
        Call("screen-3", "old", b),
        Call("screen-4", "old", b),
    )
    val received = refreshAll("new", calls)
    val arrivals = probe(listOf(a1, a2, b), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=refresh.a | copies=1 -> once
event-2: key=refresh.a | copies=1 -> once
event-3: key=refresh.b | copies=2 -> duplicated
once: 2
duplicated: 1
superseded: 0
lost: 0
```

Now the same four calls run with single flight. While the first refresh is in progress, the second of the same key is dropped.

```kotlin
// Main.kt — single flight drops the later refresh of the same key
fun main() {
    val a1 = Signal("refresh.a", 1)
    val a2 = Signal("refresh.a", 2)
    val b = Signal("refresh.b", 3)
    val calls = listOf(
        Call("screen-1", "old", a1),
        Call("screen-2", "old", a2),
        Call("screen-3", "old", b),
        Call("screen-4", "old", b),
    )
    val received = singleFlight("new", calls)
    val arrivals = probe(listOf(a1, a2, b), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=refresh.a | copies=1 -> once
event-2: key=refresh.a | copies=0 -> lost
event-3: key=refresh.b | copies=1 -> once
once: 2
duplicated: 0
superseded: 0
lost: 1
```

Single flight reduced the two calls that share the same event to one copy: `refresh.b` went from duplicated to once. The second `refresh.a`, a different event, never reached the server and was lost. This event is not superseded. To be superseded, a greater `seq` under the same key would have to arrive; the only copy that arrived carries `seq` 1, which is smaller than the dropped event. The rule sees only a later event taking the place of the one before it; it does not see an earlier event taking the place of the later one.

The cost of this is real for the app. A dropped refresh means that the second call never sent its own token to the server; the call must wait for the result of the first refresh. The measurement shows not this wait but only which event arrived.

**SE3.** The model keeps single flight per key and never marks that a refresh has finished; while one refresh is in progress, the second of the same key is always dropped. On the platform, a refresh finishes and the next refresh can start again.

## What the Receiving Side Has to Do

The receiver is the server, but most of reducing copies is done in the client. There are three jobs.

- **Start the refresh from a single point.** Not every call sends a refresh; a call that receives the unauthorized response goes to a shared refresh function, and the function applies the single flight rule. That way the rule is written in one place, and screens do not have to know it.
- **Put the dropped call on hold.** A call that does not send the refresh does not give up; it waits for the result of the refresh in progress and resends its own request with the new token. This step is not in the model, but the dropped event staying lost makes it necessary.
- **Know how the server treats the refresh token.** If the server changes the refresh token on every use, the second copy arrives with an invalid token and is rejected; whether this signs the user out is the server's rule and is not measured here. From the client's point of view, single flight is the cheapest way of not asking this question.

Keeping the refresh flow in a single place also makes debugging easier: when the copy count goes up, there is only one place to look. The screen to show the user is also decided in this function: if the refresh succeeds, the user sees nothing, and if it fails, the user is redirected to the sign-in screen. Because the result of the dropped call is tied to the result of the refresh, the call must not show a separate error screen.

On the server side, adding a request identifier to the refresh request makes it possible to deduplicate copies. This does not change the delivery: the copy has still arrived, but the result is not repeated.

## Common Misconceptions

**Biometric authentication authenticates to the server.** The authentication is local; it does not tell the server who the user is. The server learns the identity from the token. That is why the authentication was not measured in the model, only the refresh after the gate.

**Single flight never loses an event.** It does. It dropped a different event of the same key, and the rule counted it as lost. The gain of single flight is that duplicated goes down to once; the cost is the dropped event.

**A dropped event is superseded.** To be superseded, a greater `seq` under the same key must arrive afterwards. The dropped event is the second one, and the copy before it has arrived; the rule counts this as lost, not superseded.

**A copy that arrives with the same token is harmless.** If the server renews the token on use, it is not. Whether it is harmless depends on the server's rule, not on the client's assumption.

**If every call renews its own token, there is no problem.** In the measurement, when every call had its own token, two refreshes arrived once, and the third call sent no refresh. But this result belongs to a flow in which every call carries a separate refresh event. When calls carry the same event of the same session, copies arise, as in the concurrent measurement.

**The duplicated count equals the number of calls.** It does not. The copy count depends on how many calls carry the same event. In the six-call measurement, the copies of one event were two and of another three; the number of calls was six, and the copy count equaled none of them.

**The user does not see the refresh.** The user does not see it, but sees its result. If the refresh fails, the user is signed out; this lesson does not measure failure, it only counts how many refreshes reached the server.

## Summary

- This lesson's delivery: duplicated. Concurrent calls of the same session send the same refresh event to the server more than once.
- Biometric authentication is a gate on the device; account-based sign-in gives the server an access token and a refresh token.
- The refreshes of separate sessions arrive as one copy each: once 2. Two and three calls of the same session produce two and three copies: duplicated 2, once 1.
- Single flight reduces the copies of the same event to one copy: the six calls that were duplicated 2 in the unprotected flow become once 3 with single flight. But it drops the later, different event of the same key: once 2, lost 1.
- A dropped event is not superseded, because the copy that arrived carries a smaller `seq`.

## Next Step

The token was refreshed and the requests are reaching the server. The next question is what the app itself is: who the package installed on the device came from. In the next lesson, we look at whether a released version updates the app installed on the device, as long as it is signed with the same certificate.
