Authentication, Token Refresh, and Offline Authorization Decisions

Capítulo 13

Estimated reading time: 14 minutes

+ Exercise

Why authentication behaves differently in offline-first apps

In an online-only app, authentication is often treated as a single gate: if the server accepts your credentials and returns a session, the app proceeds; if not, the user is blocked. Offline-first apps must separate three related but distinct problems: (1) proving identity (authentication), (2) proving permissions (authorization), and (3) deciding what the app should do when it cannot consult the server (offline authorization decisions). The tricky part is that tokens expire, permissions can change, and devices can be lost—all while the app may be offline for hours or days.

To build a resilient user experience, you need a plan for: storing credentials and tokens safely, refreshing tokens without interrupting the user, handling refresh failures, and making explicit offline decisions about what the user can see and do. These decisions should be consistent, auditable, and designed to minimize security risk while still enabling productive offline work.

Core building blocks: access tokens, refresh tokens, and claims

Access token

An access token is a short-lived credential presented to APIs. In many systems it is a JWT (JSON Web Token) with claims such as user id, tenant, roles, and expiration. In other systems it is an opaque string that the server validates. For offline-first apps, the most important property is that access tokens expire frequently (minutes to an hour). That means your app must expect to operate with an expired access token while still letting the user work offline.

Refresh token

A refresh token is a longer-lived credential used to obtain a new access token. It should be treated like a password: if stolen, it can be used to mint new access tokens. Many platforms recommend refresh token rotation (each refresh returns a new refresh token and invalidates the previous one) to reduce replay risk.

Claims and authorization data

Authorization is about what the user is allowed to do. Some of that information may be embedded in access token claims (roles, scopes), and some may be server-side (fine-grained permissions, resource-level ACLs). Offline-first apps often need a local representation of authorization state to make decisions while offline. The key is to treat local authorization as a best-effort approximation with explicit limits, not as a perfect mirror of server truth.

Continue in our app.
  • Listen to the audio with the screen off.
  • Earn a certificate upon completion.
  • Over 5000 courses for you to explore!
Or continue reading below...
Download App

Download the app

Design goals and threat model for offline authorization

Before implementation details, define what you are optimizing for. Typical goals include: (1) minimize user disruption when offline, (2) avoid unsafe privilege escalation, (3) reduce exposure if the device is compromised, (4) support rapid revocation when back online, and (5) keep the logic understandable and testable.

Common threats and failure modes to plan for:

  • Stolen device: attacker gains access to local storage and cached tokens.
  • Revoked access: user is removed from a team or loses a role while offline.
  • Expired tokens: access token expires while offline; refresh cannot happen.
  • Clock skew: device time is wrong, causing premature expiration or over-trusting tokens.
  • Replay of refresh token: refresh token is copied and reused.
  • Offline privilege creep: app continues to allow sensitive actions indefinitely without server confirmation.

Local session model: what to store on device

Offline-first authentication typically uses a local session record that includes both security-critical and UX-critical fields. A practical model might include:

  • userId, tenantId (or equivalent)
  • accessToken (optional to persist; see below)
  • accessTokenExpiresAt (server-provided if possible)
  • refreshToken (persisted in secure storage)
  • refreshTokenExpiresAt (if provided)
  • scopes/roles snapshot (for offline decisions)
  • lastOnlineAuthAt (when server last confirmed auth)
  • offlineGraceUntil (policy-driven cutoff)
  • deviceKeyId (if using device-bound tokens)

Where to store: refresh tokens should go into platform secure storage (Keychain on iOS, Keystore-backed storage on Android). Access tokens can be stored in memory and reloaded from secure storage if you must persist them, but many teams choose to persist access tokens only if needed for background API calls. Authorization snapshots (roles/scopes) can be stored in your local database, but treat them as untrusted inputs for sensitive operations.

Token refresh strategy: proactive, reactive, and coordinated

Token refresh is not just a network call; it is a coordination problem. Multiple API requests may fail at the same time due to expiration, and you must avoid a “refresh storm” where each request triggers its own refresh.

Reactive refresh (on 401/invalid token)

In reactive refresh, you attempt the API call; if the server responds with an authentication error (often 401), you refresh the token and retry once. This is simple and works well when connectivity is stable.

Proactive refresh (before expiry)

In proactive refresh, you refresh when the access token is close to expiring (for example, within 2–5 minutes). This reduces failed calls and improves UX, especially when the app is about to start a burst of network activity.

Single-flight refresh (deduplicate concurrent refresh attempts)

Regardless of proactive or reactive, implement a single-flight mechanism so only one refresh happens at a time. Other requests should await the same refresh result.

// Pseudocode: single-flight token refresh gatelet refreshInFlight = nullasync function getValidAccessToken() {  if (session.accessToken && !isExpiringSoon(session.accessTokenExpiresAt)) {    return session.accessToken  }  if (refreshInFlight) {    return await refreshInFlight  }  refreshInFlight = (async () => {    try {      const res = await authApi.refresh(session.refreshToken)      // If using rotation, store new refresh token atomically      session.accessToken = res.accessToken      session.accessTokenExpiresAt = res.expiresAt      session.refreshToken = res.refreshToken ?? session.refreshToken      session.lastOnlineAuthAt = now()      persistSessionSecurely(session)      return session.accessToken    } finally {      refreshInFlight = null    }  })()  return await refreshInFlight}

Atomicity note: if refresh token rotation is enabled, you must persist the new refresh token before using it elsewhere. If the app crashes after receiving a rotated token but before persisting it, you can end up with a refresh token that the server has already invalidated. To mitigate, write the new refresh token first (or in the same atomic transaction as the session update) and only then update in-memory state.

Handling refresh failures without breaking offline work

Refresh can fail for many reasons: no connectivity, server unavailable, refresh token expired, refresh token revoked, or rotation mismatch. Your app should classify failures into categories that drive UX and security behavior.

Failure categories

  • Transient network failure: keep the user signed in locally; mark session as “needs reauth when online”; continue offline within policy.
  • Invalid refresh token (revoked/expired/rotation error): require reauthentication; decide what offline data remains accessible (often read-only or locked).
  • Server says account disabled: lock immediately; do not allow offline continuation beyond minimal safe access (often none).

Step-by-step: refresh failure decision flow

  • Step 1: Attempt refresh only when you have connectivity (or when a request fails and connectivity is available).
  • Step 2: If refresh fails due to connectivity/timeouts, set authState = OFFLINE_STALE and keep local session.
  • Step 3: If refresh fails due to invalid_grant/unauthorized, set authState = REAUTH_REQUIRED, clear access token, and optionally clear refresh token depending on your security posture.
  • Step 4: If refresh fails due to account disabled, set authState = LOCKED, clear tokens, and restrict local data access per policy.
  • Step 5: Surface a UI banner or blocking screen appropriate to state (banner for OFFLINE_STALE, blocking for REAUTH_REQUIRED/LOCKED).

Offline authorization decisions: define explicit policies

Offline authorization is the set of rules your app uses to decide what the user can do without consulting the server. The safest approach is to define policies per capability, not a single global rule. For example, viewing previously downloaded content may be allowed longer than creating new financial transactions.

Common policy patterns

  • Time-bounded offline access: allow offline use until a cutoff (e.g., 24 hours since last successful online auth). After that, require reauth before allowing any access.
  • Read-only after expiry: allow viewing cached data but block writes when tokens are expired and cannot be refreshed.
  • Capability-based offline allowlist: explicitly list which actions are allowed offline (e.g., draft creation, note-taking) and which are not (e.g., approvals, payments).
  • Data sensitivity tiers: classify data as public/internal/confidential and apply stricter offline rules to confidential data.
  • Step-up authentication: require local biometric/PIN for sensitive offline actions even if the user is “signed in”.

Practical example: three-tier offline policy

Imagine a field service app used in areas with poor connectivity:

  • Tier A (low risk): view assigned work orders already downloaded. Allowed offline for 7 days since last online auth.
  • Tier B (medium risk): create notes and photos attached to a work order. Allowed offline for 48 hours; requires device unlock (biometric) every 12 hours.
  • Tier C (high risk): mark a work order as completed (which triggers billing). Allowed only when online with a fresh token (or within 5 minutes of last online auth).

This approach makes the offline experience productive while limiting the risk of unauthorized high-impact actions.

Implementing offline authorization checks in the app

Offline authorization should be enforced in the same place you enforce online authorization in the client: at the boundary where commands are created and executed. Do not rely only on UI hiding buttons; enforce checks in your domain/service layer so background processes and deep links cannot bypass them.

Suggested structure

  • AuthSessionManager: owns tokens, refresh, and auth state transitions.
  • OfflinePolicyEngine: evaluates whether a capability is allowed given current session, time since last online auth, and local step-up state.
  • CommandFactory: creates domain commands (createNote, completeWorkOrder) only if policy allows; otherwise returns a structured error.
// Pseudocode: capability checkenum Capability { VIEW_CACHED, CREATE_DRAFT, COMPLETE_JOB }function canPerform(capability, context) {  if (authState === 'LOCKED') return { ok: false, reason: 'locked' }  const nowTs = now()  const last = session.lastOnlineAuthAt  if (!last) return { ok: false, reason: 'never_authenticated' }  const hoursSince = (nowTs - last) / 3600000  if (capability === Capability.VIEW_CACHED) {    return hoursSince <= 168 ? { ok: true } : { ok: false, reason: 'reauth_required' }  }  if (capability === Capability.CREATE_DRAFT) {    if (hoursSince > 48) return { ok: false, reason: 'reauth_required' }    if (!context.recentStepUp) return { ok: false, reason: 'step_up_required' }    return { ok: true }  }  if (capability === Capability.COMPLETE_JOB) {    return isOnline() && hasFreshAccessToken() ? { ok: true } : { ok: false, reason: 'online_required' }  }  return { ok: false, reason: 'unknown_capability' }}

Important: even if the client allows an action offline (like creating a draft), the server must still enforce authorization when the action is eventually sent. Client-side offline authorization is about UX and risk reduction, not about guaranteeing security.

Dealing with permission changes while offline

Permissions can change while the device is offline: a user might be removed from a project, or their role might be downgraded. If you cache roles/scopes locally, you risk allowing actions that are no longer permitted. You cannot fully solve this without server contact, but you can reduce exposure:

  • Short offline grace for sensitive capabilities: the higher the impact, the shorter the allowed offline window.
  • Use “issued at” and “auth time”: store when the server last authenticated the user; require periodic online revalidation.
  • Prefer narrowing over widening: if local permission data is missing or ambiguous, deny or degrade to read-only.
  • On reconnect, re-evaluate queued actions: before sending, re-check server authorization; if denied, mark the action as rejected and guide the user to resolve.

Token expiry, device time, and safe time calculations

Offline apps frequently run on devices with incorrect clocks. If you rely on device time to decide whether a token is expired, you may incorrectly treat an expired token as valid (security risk) or treat a valid token as expired (UX issue). Practical mitigations:

  • Prefer server-provided expiry timestamps and store them as absolute times.
  • Use a safety margin: treat tokens as expiring a few minutes early.
  • Track server time offset when you have a response header like Date; store an estimated offset and use it for expiry comparisons.
// Pseudocode: compute "server now" using stored offsetfunction serverNow() {  return now() + (session.serverTimeOffsetMs ?? 0)}function isExpiringSoon(expiresAt) {  const marginMs = 2 * 60 * 1000  return serverNow() >= (expiresAt - marginMs)}

Step-up authentication for offline-sensitive actions

Step-up authentication means requiring an additional local proof (biometric, device PIN, app-specific PIN) before allowing certain actions. This is especially useful offline because you cannot consult the server for risk signals. Step-up can be time-boxed: once the user passes biometric, allow sensitive actions for the next N minutes.

Implementation steps:

  • Step 1: Define which capabilities require step-up (e.g., exporting data, viewing confidential notes, approving items).
  • Step 2: Implement a local “stepUpValidUntil” timestamp stored securely (or in memory if you accept it resetting on app restart).
  • Step 3: When capability is requested, if step-up is required and expired, prompt biometric/PIN.
  • Step 4: If step-up succeeds, set stepUpValidUntil = now + duration.
  • Step 5: If step-up fails, deny the action with a clear reason.

Multi-account and account switching considerations

Offline-first apps often support multiple accounts (personal + work) or multiple tenants. Token refresh and offline authorization must be scoped correctly:

  • Separate secure storage entries per account (keyed by userId/tenantId).
  • Separate local databases or strong logical partitioning to avoid data leakage across accounts.
  • Refresh isolation: a refresh failure for one account should not invalidate others.
  • UI clarity: show which account is active when offline, especially if permissions differ.

Logout, local data retention, and “offline sign-out”

Logout in offline-first apps is not just clearing a cookie. You must decide what happens to locally stored data and whether the user can still access anything offline after logout.

Common approaches

  • Hard logout: clear tokens and wipe encrypted local data. Highest security, but can be disruptive if logout was accidental.
  • Soft logout: clear tokens but keep encrypted data; require login to decrypt. This can be a good balance if you encrypt local data with a key derived from secure hardware or user credentials.
  • Offline sign-out: user chooses to sign out while offline; you cannot revoke server sessions immediately, but you can clear local tokens and mark the device as signed out. On next online, you can call a revocation endpoint if you still have a revocation token; if not, rely on refresh token invalidation policies server-side.

Make the behavior explicit in product requirements: for regulated data, hard logout and local wipe may be mandatory. For consumer apps, soft logout is often acceptable.

Practical step-by-step: end-to-end flow for resilient auth

This step-by-step sequence ties together token refresh and offline authorization decisions in a typical app lifecycle.

1) Sign-in (online)

  • Authenticate with the server (password/OAuth/SSO).
  • Receive access token + refresh token (+ expiry times).
  • Store refresh token in secure storage; store access token in memory (and optionally secure storage).
  • Store lastOnlineAuthAt = serverNow(); store serverTimeOffsetMs if available.
  • Fetch an authorization snapshot needed for offline decisions (roles/scopes, plus any coarse-grained entitlements).

2) Normal API calls (online)

  • Before request, call getValidAccessToken() (single-flight).
  • Attach token; if 401, refresh and retry once.
  • If refresh fails transiently, mark authState = OFFLINE_STALE and continue with offline-capable features.

3) Going offline

  • Detect offline state via your connectivity model (already implemented elsewhere).
  • Stop attempting refresh; keep session but mark “cannot validate with server”.
  • For each user action, call OfflinePolicyEngine.canPerform(capability).
  • If denied, provide a reason-specific UX: “Requires connection”, “Re-authentication required”, or “Unlock to continue”.

4) Access token expires while offline

  • Do not treat this as immediate logout; treat it as “cannot call server”.
  • Continue offline actions allowed by policy (often read-only or draft-only).
  • Record that server-bound operations must wait for reconnect and reauth if needed.

5) Reconnect

  • Attempt refresh immediately (single-flight) if session exists.
  • If refresh succeeds, update lastOnlineAuthAt and authorization snapshot if needed.
  • If refresh fails with invalid token, transition to REAUTH_REQUIRED and block server-bound operations; decide whether to lock local data based on sensitivity.
  • Before sending any queued server-bound operations, re-check authorization server-side; handle rejections gracefully.

Testing scenarios you should automate

Authentication and offline authorization are easy to get wrong because they involve time, concurrency, and state transitions. Create automated tests (unit + integration) for:

  • Concurrent requests: 10 API calls at once when token is expiring; ensure only one refresh happens.
  • Rotation crash: simulate crash after receiving rotated refresh token but before persisting; ensure recovery strategy.
  • Offline expiry: token expires while offline; verify allowed capabilities still work and disallowed ones are blocked.
  • Clock skew: device time ahead/behind; verify safety margin and server offset logic.
  • Permission downgrade: offline actions allowed locally but rejected on reconnect; verify user messaging and data integrity.
  • Account switching: ensure tokens and authorization snapshots do not leak across accounts.

Now answer the exercise about the content:

When refresh token rotation is enabled, what is the safest way to handle storing tokens after a successful refresh to avoid breaking future refresh attempts?

You are right! Congratulations, now go to the next page

You missed! Try again.

Refresh token rotation invalidates the previous refresh token. If the app crashes before persisting the new token, future refresh will fail. Persist the new refresh token first, ideally in the same atomic session update.

Next chapter

File Upload and Download Flows with Resume and Integrity Checks

Arrow Right Icon
Free Ebook cover Offline-First Mobile Apps: Sync, Storage, and Resilient UX Across Platforms
68%

Offline-First Mobile Apps: Sync, Storage, and Resilient UX Across Platforms

New course

19 pages

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.