Skip to content
academia.sh

Lesson 04 / 19

Authentication

Storing and refreshing bearer tokens; measuring whether storing two refresh responses in a different order brings back the old token.

Published Updated

In this lesson

  • Separates the roles of the access token and the refresh token
  • Measures that when two refresh responses arrive in reverse order, the last writer brings back the old token
  • Shows that storage that keeps the one with the larger serial number stays robust in the same case
  • Measures the rejection of the second use of the refresh token as repeat sensitivity
Contents

Most backend requests require authentication. The client puts a bearer token on every request: the server treats whoever carries the token as authorized. The token is short-lived; when it expires, the client asks for a new one with a long-lived refresh token. The short-lived one of the tokens is called the access token.

This mechanism is sensitive in two places. The first is storage: if refresh responses arrive in reverse order, which one is stored matters. The second is the server: a refresh token is usually single-use, and its second use is rejected. The core is the same.

// Replay.swift — classifies an operation's sensitivity to repetition and order
struct Trial<State: Equatable & CustomStringConvertible> {
    let label: String
    let once: State
    let twice: State
    let forward: State
    let backward: State

    var sensitivity: String {
        switch (once != twice, forward != backward) {
        case (false, false): return "robust"
        case (true, false): return "repeat-sensitive"
        case (false, true): return "order-sensitive"
        case (true, true): return "doubly-sensitive"
        }
    }

    func report() -> String {
        print("\(label) | once=\(once) twice=\(twice) ab=\(forward) ba=\(backward) -> \(sensitivity)")
        return sensitivity
    }
}

func replay<State>(_ label: String, from start: State, _ a: (State) -> State, then b: (State) -> State) -> Trial<State> {
    Trial(label: label, once: a(start), twice: a(a(start)), forward: b(a(start)), backward: a(b(start)))
}

func countSensitivities(_ results: [String]) {
    for name in ["robust", "repeat-sensitive", "order-sensitive", "doubly-sensitive"] {
        print("\(name): \(results.filter { $0 == name }.count)")
    }
}

Token Storage

The model stamps the token with a counter: serial says which refresh the token came from. Expiry is modeled with this counter instead of a real clock, so the measurement does not depend on the clock.

// Vault.swift — token, vault, and the server's refresh state
struct Token: Equatable {
    let value: String
    let serial: Int
}

struct Vault: Equatable, CustomStringConvertible {
    var current: Token?
    var description: String { current.map { "\($0.value)#\($0.serial)" } ?? "empty" }
}

func store(_ token: Token) -> (Vault) -> Vault {
    { _ in Vault(current: token) }
}

func storeNewer(_ token: Token) -> (Vault) -> Vault {
    { vault in
        guard let held = vault.current, held.serial >= token.serial else {
            return Vault(current: token)
        }
        return vault
    }
}

struct Issuer: Equatable, CustomStringConvertible {
    var valid: String
    var issued: Int = 1
    var rejected: Int = 0
    var description: String { "valid=\(valid) issued=\(issued) rejected=\(rejected)" }
}

func redeem(_ refresh: String) -> (Issuer) -> Issuer {
    { issuer in
        guard refresh == issuer.valid else {
            return Issuer(valid: issuer.valid, issued: issuer.issued, rejected: issuer.rejected + 1)
        }
        return Issuer(valid: "r\(issuer.issued + 1)", issued: issuer.issued + 1, rejected: issuer.rejected)
    }
}

store stores the incoming token unconditionally; the last writer wins. storeNewer stores only a token with a larger stamp; one that arrives with an equal or smaller stamp is ignored. In the measurement below there are two refresh responses: the old one, a1, comes from the first refresh, and the new one, a2, from the second. If two concurrent refresh requests leave at the same moment, the order in which the responses return is not certain.

// main.swift — storing two refresh responses
let stale = Token(value: "a1", serial: 1)
let fresh = Token(value: "a2", serial: 2)

let lastWins = replay("last-write-wins", from: Vault(), store(stale), then: store(fresh)).report()
let newerWins = replay("larger-serial-wins", from: Vault(), storeNewer(stale), then: storeNewer(fresh)).report()

countSensitivities([lastWins, newerWins])
last-write-wins | once=a1#1 twice=a1#1 ab=a2#2 ba=a1#1 -> order-sensitive
larger-serial-wins | once=a1#1 twice=a1#1 ab=a2#2 ba=a2#2 -> robust
robust: 1
repeat-sensitive: 0
order-sensitive: 1
doubly-sensitive: 0

last-write-wins is order-sensitive. If the old response comes later, a1 stays in the vault as in the ba column; the app keeps sending requests with an old token that has expired or been replaced by a new one. larger-serial-wins is robust: a2 stays in both orders, and storing the same response twice changes nothing.

NW10. The model assumes the server issues an increasing stamp on every refresh. With a server that issues no stamp, the client cannot establish the order on its own.

NW11. The old token may or may not still be valid on the server. The measurement looks only at what the client stores.

The Token on the Request and Single Flight

The bearer token is added to the request with the Authorization header.

import Foundation

func authorize(_ request: URLRequest, token: String) -> URLRequest {
    var signed = request
    signed.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    return signed
}

This snippet stays SOURCE. Another way to solve the order problem before storage is single flight: only one refresh runs at a time, and the other callers wait for the result of that refresh. An actor builds this naturally.

// token provider — SOURCE
actor TokenProvider {
    private var refreshTask: Task<String, Error>?

    func validToken(refresh: @escaping @Sendable () async throws -> String) async throws -> String {
        if let task = refreshTask { return try await task.value }
        let task = Task { try await refresh() }
        refreshTask = task
        defer { refreshTask = nil }
        return try await task.value
    }
}

This snippet is not measured either. The second caller finds refreshTask filled and does not start a new refresh. However, the actor’s state is consistent only between these await points; if the vault is written from somewhere else while a refresh is in progress, the same order problem comes back.

The Lifetime of the Access and Refresh Tokens

The two tokens do different jobs. The access token goes with every request; its lifetime is short, because if it is stolen the damage is short too. The refresh token is used only in a refresh request; its lifetime is long, because it saves the user from signing in again every half hour. The long-lived one is kept in fewer places and more securely. In apps the refresh token usually lives in the system’s keychain; the access token can be kept in memory, because if it is lost it is obtained again by refreshing.

This distinction explains why the measurement looks at two refresh responses. When the access token expires, the client asks for a new pair with the refresh token. If several requests have left at the same time with the expired token, all of them get a 401 and all of them want to refresh. Two requests with the same refresh token reach the server, and one of the responses returns before the other. The two responses carry two separate pairs; depending on which one it stored, the client continues with either the fresh or the stale token.

Where It Shows Up in a Real App

When a home page opens, the app sends three requests at the same time, for the profile, the notification count, and the recommendation list. If the access token has expired at that moment, all three return 401. A naive client sends three separate refresh requests. If the server keeps refresh tokens single-use, the first succeeds, the other two are rejected, and the user sees their session close. This is the everyday form of the token-rotation line in the measurement: the first use consumes the token and the second is rejected.

The problem may not show to the user as an error. The app may have silently returned to the sign-in screen, and the user complains that it “signed out by itself”. In the logs only a “refresh rejected” line appears. The real cause is that the refresh was started more than once. So a rule is needed that makes sure only one refresh runs at a time. This rule is called single flight: while a refresh is in progress, every request that arrives does not start a new refresh, it waits for the result of the one in progress.

The Wrong Setup and Its Fix

In the wrong setup every failed request starts its own refresh.

import Foundation

// Wrong: three concurrent 401s start three separate refreshes
func refreshAccessToken(using session: URLSession, refresh: String) async throws -> String {
    var request = URLRequest(url: URL(string: "https://api.example.com/refresh")!)
    request.httpMethod = "POST"
    request.httpBody = Data(refresh.utf8)
    let (data, _) = try await session.data(for: request)
    return String(decoding: data, as: UTF8.self)
}

This snippet stays SOURCE. The fix is to gather the refresh in a single place: an actor holds the refresh in progress as a task and gives every call that reaches it the result of the same task.

import Foundation

actor RefreshCoordinator {
    private var inFlight: Task<String, Error>?

    func freshToken(using perform: @escaping () async throws -> String) async throws -> String {
        if let inFlight { return try await inFlight.value }
        let task = Task { try await perform() }
        inFlight = task
        defer { inFlight = nil }
        return try await task.value
    }
}

This also stays SOURCE. The actor guarantees that only one call touches its state at a time; so inFlight does not race between two calls. The first call starts the task, the later ones wait, and all continue with the same token. The solution resets repetition: a single refresh reaches the server. The token-rotation line in the measurement provides no protection for this, because the model builds what the server does; the side that applies the protection is the client.

This solution still has a limit. Single flight prevents concurrent refreshes; it does not prevent the storage order of two responses. A late response left over from a previous refresh can still write the stale token to the vault if it is stored after the new one. So a stamp comparison like storeNewer is also needed: the two protections solve two separate problems.

Where the Token Is Stored

The Vault in the model is a vault; in a real app its counterpart has two layers. Memory gives fast access while the app is running and is lost when the app closes. The system’s keychain stays after closing and keeps its data encrypted. Writing the token to a plain store such as user preferences is a common and serious mistake: a plain store goes into backups and file dumps as it is. The keychain will be covered separately in the next topic; what matters here is that storing is a write operation, and a write operation is subject to this whole course’s measure.

The subtle point in storing is that writing to the keychain can be an asynchronous job. If two refresh responses arrive back to back, both writes are sent, and which one reaches the store last may not be the same as which one was called last. That is why the last-write-wins line in the measurement is realistic: the model builds the write itself as an order-sensitive operation. The fix is to carry the order information in the value itself; that is why the larger-serial-wins line came out robust.

Signing Out

When the user signs out, the client does two things: it deletes the tokens in the vault and has the server revoke the refresh token. Deleting is a local operation and is robust against repetition: emptying an empty vault once more changes nothing. The revoke request goes to the server and must be retryable; for this reason revoking is designed as an idempotent operation. If a refresh is still in progress at the time of sign-out, the late-returning response can fill the vault again. The session coming back while the user has signed out is the most uncomfortable form of the order problem. To prevent this, sign-out cancels the refresh in progress, and before storing a returning response it is checked whether the session is still open.

If the Refresh Itself Fails

The refresh request is also a request and can fail. Two cases need to be told apart here. If the refresh token has been rejected, that is, the server responded and treated the token as invalid, the session has really ended and the user has to sign in again. If the refresh request has dropped because of a network error, the session is intact; only the connection is temporary. A client that mixes up these two cases throws a user whose connection dropped briefly out of the session unnecessarily.

The right behavior is to apply the classification in the third lesson here too. Timeout and no connection are retryable errors; the refresh is retried within the budget and the token in the vault is not deleted. The server’s explicit rejection, on the other hand, empties the vault and sends the user to the sign-in screen. The rejected counter in the token-rotation line is the model of this second case. A counter greater than zero shows that the server rejected a refresh token; when the client sees this, it should end the session instead of retrying.

Refreshing Before Expiry

Refreshing can be done at two times. In the reactive approach, the token is refreshed when a request gets a 401; the code is simple, but at every expiry one request is wasted and all the requests that left at the same time fail. In the proactive approach, the access token is refreshed shortly before it expires; so a 401 is never seen. This requires the server to give the expiry information in the response and relies on the client clock. If the clock has drifted, the token is refreshed while it is actually still valid, or is thought valid when it has expired.

In practice the two are used together: proactive refresh prevents most errors, and reactive refresh catches the cases that slip through, such as clock drift. Both paths must go through the single flight rule; otherwise proactive and reactive refresh leave at the same time and use the same refresh token twice.

Common Mistakes

  • Storing the refresh response unconditionally. A late-arriving old response overwrites the new token.
  • Starting a separate refresh after each failed request. If requests get 401 at the same time, several refreshes race.
  • Using the refresh token a second time. The server may reject it and may close the session as well.
  • Writing the token to a log or putting it in an error message.
  • Tying expiry to the client clock. Clock drift produces wrong decisions.

Interpreting the Measured Result

Two storage rules received the same two responses and gave different results. The difference is not in the responses but in the rule: one looked at the order, the other at the stamp. Because the stamp carried the order into the response, the arrival order became unimportant. This is a pattern often used on the network: writing the order into the data instead of expecting it from the network.

The Bounding Measurement

Even if storage is robust, the refresh token is still unprotected against repetition. Below, the same refresh token is used twice on the server.

// main.swift — the second use of the same refresh token
let reuse = replay("token-rotation", from: Issuer(valid: "r1"), redeem("r1"), then: redeem("r1")).report()
countSensitivities([reuse])
token-rotation | once=valid=r2 issued=2 rejected=0 twice=valid=r2 issued=2 rejected=1 ab=valid=r2 issued=2 rejected=1 ba=valid=r2 issued=2 rejected=1 -> repeat-sensitive
robust: 0
repeat-sensitive: 1
order-sensitive: 0
doubly-sensitive: 0

The first use consumes r1 and makes r2 valid; the second use finds r1 invalid and rejects it. This is token rotation, and it came out repeat-sensitive in the measurement. The two orders gave the same result, because the two operations were the same. The rule does not say whether this rejection is a security feature or a bug.

NW12. The server is assumed to reject the old refresh token immediately. Some servers allow a short grace period; this behavior was not measured.

Summary

  • The access token is short-lived; the refresh token is long-lived and serves to obtain a new one.
  • Storing refresh responses unconditionally is order-sensitive; a late-arriving old response overwrites the new token.
  • Storage that keeps the one with the larger stamp left the same token in both orders.
  • Single flight solves the problem from another place by running one refresh at a time.
  • The second use of the refresh token was rejected, and this came out repeat-sensitive.
  • This lesson’s sensitivity: order-sensitive.

Next Step

After the token and the responses arrive, some of them need to be stored. In the next lesson, Image and Data Caching will measure whether the same pair of operations comes out robust in an empty cache and order-sensitive in a full one.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close