Skip to main content
ANVISoftware Solutions
Lesson 7 of 11Intermediate19 min

Authentication on Device

By the end of this lesson

Sign users in and store tokens using platform secure storage.

A server keeps its secrets in an environment you control: a machine in a data centre, behind a network you configured, with access you granted. A mobile app keeps its secrets on a device that travels in a coat pocket, gets left on a van seat, and is occasionally handed to a colleague to show them something.

That changes what you store and how. The field app needs to stay signed in — asking an engineer to type a password at every site would get the app abandoned by Wednesday — so something that grants access to the employees API lives on that device for weeks. Where it lives is the subject of this lesson.

The framing throughout is defensive: how to build so that a lost device is a contained problem rather than an open door. Two facts shape every decision. Anything shipped inside your app is shipped to everybody who installs it, so the app cannot hold a secret of its own. And the platform provides storage specifically designed to protect credentials, backed on modern devices by dedicated hardware, which ordinary preference stores and plain files are not.

Terms used precisely for the rest of this lesson:

Access token
The credential sent with each API request. Short-lived on purpose, typically minutes rather than days, so that a copy which escapes is useful for a small window. It should carry only the permissions the app needs.
Refresh token
A longer-lived credential whose only job is to obtain a new access token. It is the more valuable of the two and the one that keeps the engineer signed in across weeks. It goes to the token endpoint and nowhere else — never to your API, never in a log.
Platform secure storage
Keychain on iOS and the Keystore-backed storage on Android. Both are built for small secrets, both are encrypted, and on current devices both are protected by hardware separate from the main processor. This is where tokens belong. An ordinary preference store or a file in your app's folder offers none of that protection and is the wrong place for a credential.
Public client
An application that cannot keep a secret because it is distributed to users. Your mobile app is one. It therefore uses an authentication flow designed for that situation, where the client proves it started the exchange using a value it generates freshly each time, rather than by presenting a secret baked into the build.
Biometric unlock
A device-local check — fingerprint or face — that confirms the person holding the device is the enrolled owner. It gates access to a credential you already stored. It is not itself a credential, and it produces nothing your server can verify.
A token response, with placeholders where real values would sit
JSON
{
  "token_type": "Bearer",
  "access_token": "<short-lived access token>",
  "expires_in": 900,
  "refresh_token": "<refresh token>",
  "scope": "employees.read visits.write"
}
  • The field names follow the usual conventions for token endpoints, so a response from your identity provider will look close to this even though the details differ.
  • expires_in is a duration in seconds, not an instant. Nine hundred seconds is fifteen minutes. Convert it to an absolute expiry as soon as the response arrives, because by the time you use the token you no longer know when it was issued.
  • Refresh a little before expiry rather than at it. A minute of headroom absorbs clock differences between the device and the server, and avoids a race where a request leaves with a token that expires in transit.
  • A device clock can be wrong, sometimes by hours, and users do change it. So treat your own expiry calculation as an optimisation, not a guarantee, and always handle a 401 from the API by refreshing once and retrying — that is the check that actually holds.
  • The scope names what the app may do. Requesting only what the app needs limits the damage if a token escapes, and it is one of the few security improvements that costs nothing to implement.
  • This whole response is sensitive. It does not belong in a log line, a crash report, an analytics event, or a debugging screenshot. Redaction has to happen before the value reaches any of those, not afterwards.
Reading a token, refreshing once, and never twice at the same time
TypeScript
interface SecureStore {
  get(key: string): Promise<string | null>;
  set(key: string, value: string): Promise<void>;
  remove(key: string): Promise<void>;
}

interface Tokens {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;
}

export class SignedOutError extends Error {}

const TOKEN_KEY = "auth.tokens";
const EARLY_REFRESH_MS = 60000;

let inFlight: Promise<Tokens> | null = null;

export async function accessToken(
  store: SecureStore,
  now: number,
): Promise<string> {
  const stored = await store.get(TOKEN_KEY);
  if (stored === null) throw new SignedOutError();

  const tokens = JSON.parse(stored) as Tokens;
  if (now < tokens.expiresAt - EARLY_REFRESH_MS) {
    return tokens.accessToken;
  }

  // One refresh at a time. Four screens waking together must not
  // each spend the refresh token.
  inFlight ??= refreshTokens(store, tokens.refreshToken).finally(() => {
    inFlight = null;
  });

  return (await inFlight).accessToken;
}
  • SecureStore is deliberately a three-method interface. Your platform's secure storage API is nothing like this, and that is fine — wrapping it behind a small interface is what lets the rest of your app stay unaware of the difference, and lets tests run without a device.
  • Every request goes through this function, which is the point. A token read directly from storage at a call site is a token nobody refreshed, and it will start returning 401s at the least convenient moment.
  • The single-flight guard on inFlight matters more than it looks. When the app returns to the foreground, several screens often refresh at once. Without the guard, each fires its own refresh; with rotating refresh tokens, where each use issues a new one and invalidates the old, the concurrent attempts invalidate each other and the user is signed out for no reason. This is a genuinely common production bug and hard to reproduce on a fast connection.
  • The finally clause clears inFlight whether the refresh succeeded or failed. Leave a rejected promise in that variable and every later call gets the same old failure forever, which presents as an app that can never sign in again until it is restarted.
  • The module-level variable is the simplest thing that works here and it assumes one signed-in user per app instance, which is true for the field app. If your app supports switching accounts, the guard needs to be per account, otherwise one user's refresh can be handed to another.

Refresh will fail eventually — tokens are revoked, staff leave, passwords change, sessions expire. What you do next decides whether that is a minor interruption or a lost afternoon of work:

  1. Separate a network failure from a refusal

    No connection means you do not know anything about the session. Keep the tokens, keep the user signed in, and retry later. Signing someone out because they walked into a lift is a bad failure, and it is the most common overreaction in this area.

  2. Treat an explicit refusal as final

    When the token endpoint answers that the refresh token is invalid or expired, it will answer the same way next time. Do not retry it, and never retry in a loop — a refresh loop against a revoked token is both useless and a pattern that gets your traffic rate-limited.

  3. Remove both tokens from secure storage

    The access token is useless without refresh, and leaving either in place invites code elsewhere to try again with a credential you know is dead. Remove them together, in one place, so there is a single path to a signed-out state.

  4. Clear cached personal data

    The cached employee list holds names and contact details. Once nobody is authenticated, it should not be on screen or on disk. Clearing it at sign-out is also the moment to confirm your cache and your queue are stored separately, because the next step depends on it.

  5. Decide about pending changes, explicitly

    The queue may hold visit notes that never reached the server. Deleting them silently destroys work the engineer believes is saved. Keep them, keyed to the user they belong to, and tell the person plainly that there are unsent changes waiting for them to sign in again. If you do decide to discard, that has to be a stated product decision and the user has to be told.

  6. Return the user to sign-in with an explanation

    Say that the session ended and what happens to their unsent work. A silent bounce to a sign-in screen reads as a bug, and a user who thinks the app is broken does not trust it with the next visit note.

Summary

  • Tokens belong in Keychain or Keystore-backed storage, never in ordinary preferences, plain files, the app bundle or logs
  • A shipped app cannot keep a client secret, so use a flow built for public clients with short-lived, narrowly scoped access tokens
  • Route every request through one function that refreshes early and allows only one refresh at a time
  • A refused refresh is final and means signing out cleanly; a network failure is not, and must not end the session
  • Biometric unlock gates access to a stored credential and proves nothing to your server, so always provide a passcode fallback

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

What does the biometric prompt actually prove?

The field app shows a fingerprint prompt at launch. The engineer's finger is recognised and the app opens on their visit list.

Write down exactly what your server knows at that moment, and what would have to be true for the visit list to load. Then decide what should happen if the app has no connection when the prompt succeeds.

Show solution

Your server knows nothing about the fingerprint. The prompt was answered by the device, and no part of that exchange reached your API. What allows the visit list to load is the access token the app holds, or one it obtains with the stored refresh token.

So the correct sequence is: the biometric check unlocks the credential in secure storage, the credential authenticates the request, and the server decides. Reverse those and you have an app where opening it is the authentication, which is no authentication at all.

With no connection, the prompt still has a job. It gates access to locally cached data — an employee list holding names and phone numbers — which is worth protecting on a device that might not be in its owner's hands. Let the engineer read the cache and write queued notes, and be clear that nothing has been sent.

The general principle worth carrying away: a local check controls access to something on the device, and only a credential the server verifies controls access to data on the server. Keeping those two ideas separate prevents a whole family of authentication mistakes.

Challenge

Design the signed-out path

An engineer leaves the company at 09:00 and their refresh token is revoked. At 11:30 their phone, still holding the app, comes back into signal in a plant room. The queue holds two visit notes written that morning.

Write the sequence of what the app does, what the engineer sees, and what happens to the two notes. Then write the sequence for the same device at 11:30 with no signal at all.

Show solution

With signal: the queue drain or a screen refresh triggers a token refresh, the endpoint refuses it explicitly, and the app moves to a signed-out state. Remove both tokens, clear the cached employee data so names and numbers are no longer on the device, and show a sign-in screen that says the session has ended.

The two notes are the interesting part, and there is no answer that is free. Keeping them means personal data stays on the device for someone who no longer has access, and they will never be sent. Discarding them destroys work, and if that engineer's notes were about a genuine safety issue at a site, that matters well beyond the app.

A defensible resolution: keep the notes only if a colleague can act on them, and make sure they can. Many teams handle this by attempting one final upload with the still-valid access token before it expires, precisely so that work done in good faith is not lost when access is withdrawn. That has to be designed deliberately — including whether your API accepts a submission from a revoked user — rather than emerging by accident.

With no signal the app cannot know anything has changed. Refresh fails at the transport level, and per the steps in this lesson that is not a sign-out. The engineer keeps working against cached data, and the session ends when connectivity returns. That gap is a real limitation of any token-based design on a device, and the mitigations are short access token lifetimes and not caching more than the app needs.

Which is why revocation is a server capability first and a client behaviour second. The client cannot enforce a decision it has not heard about.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A token refresh fails because the device has no connection. What should the app do?
Why is a refresh token stored in the app's ordinary preference store a problem?
Why must several screens refreshing at once share a single refresh attempt?

Saved in this browser only.