Build CoreOrdered learning track

Offline and Degraded Auth

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 070

Offline and degraded authentication/authorization in React: cached reads, queued writes, revalidation, degraded IdP/API states, service workers, permission TTL, conflict recovery, and secure sync.

8 min read1526 words
PrevNext
Lesson 70130 lesson track25–71 Build Core
#react#authentication#authorization#offline+9 more

Part 070 — Offline and Degraded Auth

Offline auth is where many frontend security models quietly collapse.

When the network is gone, the app still has:

old UI
old data
old permissions
old session projection
old feature flags
old tenant context
old service worker cache
old query cache
pending user intent

But it no longer has reliable access to:

current session validity
current permission decision
current resource state
current policy version
current revocation state
current tenant membership
current MFA/step-up freshness

That mismatch creates the central problem:

Offline state can remember what used to be true, but authorization asks what is true now.

The safest default is simple:

Offline reads may be allowed only from explicitly safe cached data.
Offline writes must be treated as local intent and reauthorized before sync.

1. Vocabulary: offline, degraded, disconnected

Use precise language.

StateMeaningExample
OnlineNetwork and auth dependencies healthyAPI, IdP, policy service reachable.
OfflineBrowser cannot reach network/APIAirplane mode, no connection.
DegradedNetwork exists but dependency is unhealthyIdP down, policy engine slow, API partial outage.
Captive/uncertainBrowser thinks online but app cannot reach APIHotel Wi-Fi login page, DNS issue.
StaleApp has cached data but cannot validate freshnessOld permissions/query cache.
ReconnectingNetwork returns and app is reconciling stateSync queue, refetch, session restore.

Do not reduce this to:

if (navigator.onLine) { ... }

navigator.onLine is a weak signal. Browsers implement it differently, and true does not prove your API, IdP, or policy engine is reachable.

Use active health checks for application dependencies.


2. Offline auth state machine

A production app should model offline/degraded auth explicitly.

export type AuthConnectivityState =
  | { kind: 'online'; checkedAt: string }
  | { kind: 'offline'; detectedAt: string }
  | { kind: 'degraded'; dependency: 'api' | 'idp' | 'policy' | 'storage'; detectedAt: string }
  | { kind: 'reconnecting'; startedAt: string }
  | { kind: 'stale'; reason: 'permission_ttl_expired' | 'session_unknown' | 'tenant_unknown' };

Do not pretend that offline is only a network banner.

Offline changes what actions are safe.


3. The offline authorization invariant

Use this invariant:

A cached permission may support UI explanation, but it must not authorize a new protected side effect while offline.

That means:

Allowed:
- show cached case detail if policy allows offline read cache
- show last-known allowed actions as stale/disabled
- let user draft comments locally
- let user prepare forms locally
- queue intent for later reauthorization

Not allowed:
- commit approval offline
- grant access offline
- delete object offline
- export sensitive data from stale cache without policy
- replay queued protected mutation without session + permission revalidation
- treat old role claim as current authority

Offline UI is allowed to preserve work.

It is not allowed to bypass policy.


4. Classify offline actions

Every product action should have an offline classification.

ClassExampleOffline behavior
Safe local preferenceTheme, table densityApply locally. Sync later if needed.
Draft-only domain dataDraft note, unsent formSave locally as draft. No server side effect.
Cached readView downloaded case summaryAllowed only if cache policy permits. Show stale marker.
Protected mutationApprove, reject, delete, assignQueue intent only; reauthorize before sync.
Access expansionGrant role, share fileUsually blocked offline.
Sensitive exportDownload evidence, export reportUsually blocked offline unless pre-authorized artifact exists.
Step-up actionHigh-risk workflow transitionBlock or require online step-up.

Do this classification at design time.

If the team does not define offline semantics, the frontend will accidentally invent them.


5. Cached read model

Cached reads can be acceptable when explicitly designed.

A cached read should carry metadata:

type CachedResource<T> = {
  data: T;
  resourceId: string;
  tenantId: string;
  fetchedAt: string;
  resourceVersion: number;
  permissionEpoch: number;
  cachePolicy: {
    offlineReadable: boolean;
    maxStaleSeconds: number;
    containsSensitiveData: boolean;
    requireDeviceProtection?: boolean;
  };
};

Render logic:

function CachedCaseView({ cached }: { cached: CachedResource<CaseDto> }) {
  if (!cached.cachePolicy.offlineReadable) {
    return <UnavailableOffline reason="This case cannot be viewed offline." />;
  }

  if (isTooStale(cached)) {
    return <UnavailableOffline reason="This cached case is too old to display." />;
  }

  return (
    <StaleDataFrame fetchedAt={cached.fetchedAt}>
      <CaseDetail data={cached.data} mode="offline-readonly" />
    </StaleDataFrame>
  );
}

Do not show sensitive cached data just because it exists.

Existence in cache is not permission.


6. Permission TTL

Permissions should have freshness constraints.

type PermissionSnapshot = {
  subjectId: string;
  tenantId: string;
  epoch: number;
  issuedAt: string;
  expiresAt: string;
  offlineUsableUntil?: string;
  decisions: Record<string, string[]>;
};

expiresAt answers:

When should online app refresh this projection?

offlineUsableUntil answers:

How long may the app use this projection for offline UI display?

For many systems:

offlineUsableUntil = none

Meaning:

Permissions cannot be used to enable protected actions offline.

That is normal.

Offline support should be deliberate, not accidental.


7. Queued writes are not queued authorizations

This is the main rule.

Bad model:

User was allowed when offline queue item was created.
Therefore replay item later.

Correct model:

User created intent while offline.
When online, authenticate, revalidate permission, revalidate resource version, then submit.

Queued item:

type OfflineIntent = {
  intentId: string;
  actorId: string;
  tenantId: string;
  action: string;
  resourceType: string;
  resourceId?: string;
  payload: unknown;
  createdAt: string;
  createdFrom: {
    resourceVersion?: number;
    permissionEpoch?: number;
    route: string;
  };
  idempotencyKey: string;
  syncPolicy: {
    requiresOnlineReauth: boolean;
    requiresPermissionRecheck: boolean;
    requiresResourceVersionCheck: boolean;
    maxQueueAgeSeconds: number;
  };
};

Sync algorithm:

for each intent:
  if session invalid -> pause and request login
  if tenant mismatch -> pause or discard with user confirmation
  if queue age expired -> mark expired
  if permission recheck fails -> mark denied
  if resource version conflict -> mark conflicted
  submit with idempotency key
  reconcile result

The queue stores intent, not authority.


8. Offline queue state machine

The dangerous shortcut is:

Queued -> Syncing

without revalidation.

Do not do that for protected mutations.


9. Service worker threat model

A service worker can intercept requests, serve cached responses, and perform background work.

That is powerful.

It also means a bad service worker strategy can bypass the app's current auth assumptions.

Problems:

serves stale authenticated response after logout
replays queued mutation after permission revocation
caches sensitive API responses accidentally
mixes tenant data in cache keys
continues background sync after session expiry
returns cache-first data for authorization-sensitive endpoint

Use explicit cache strategy per endpoint:

EndpointService worker strategy
/assets/*Cache-first or stale-while-revalidate.
/api/sessionNetwork-only, no-store.
/api/permissionsNetwork-only or short TTL with auth epoch.
/api/cases/:idNetwork-first only if offline policy allows cached read.
/api/cases/:id/approveNever cache. Never replay without revalidation.
/api/files/:id/downloadUsually network-only / signed URL policy.

Do not let generic PWA caching touch authenticated APIs by default.


10. Background Sync risk

Background Sync can defer work to a service worker and retry later.

This is useful for low-risk operations.

It is risky for protected mutations.

For auth-sensitive sync, require:

idempotency key
queue age limit
session revalidation
permission revalidation
resource version check
tenant check
explicit per-action allowlist
safe user-visible reconciliation

Do not register generic background sync like:

sync all failed POST requests

That turns every failed mutation into a future side effect.

Instead:

type SyncableActionPolicy = {
  action: string;
  syncAllowed: boolean;
  requiresForegroundConfirmation: boolean;
  requiresStepUp: boolean;
};

For high-risk actions:

requiresForegroundConfirmation = true

Meaning the user must return online and confirm after fresh authorization.


11. Auth dependency degradation

Offline is not the only failure mode.

Your app may be online while auth dependencies are degraded.

Examples:

Identity provider login endpoint down
policy service latency high
permission projection endpoint returns 503
session introspection slow
OIDC JWKS endpoint unreachable
BFF session store degraded

Do not respond by allowing everything.

Use fail modes:

DependencyFailureSafe mode
IdP loginCannot start new loginExisting sessions may continue if locally/server valid; new login unavailable.
Session storeCannot validate sessionFail closed for protected APIs.
Policy engineCannot evaluate permissionFail closed for protected mutation; maybe cached read-only if explicit.
Permission projection APICannot refresh UI permissionsShow stale/disabled actions; do not unlock new actions.
Audit pipelineCannot write auditFor critical mutation, fail closed or durable local/server queue.
Notification serviceDownCommit may continue only if notification is non-critical.

A degraded auth system should have explicit business policy.

For regulated systems, many critical actions should fail closed if audit or policy decision cannot be guaranteed.


12. Degraded UI modes

Use user-visible modes.

type DegradedMode =
  | { kind: 'read_only'; reason: 'offline' | 'policy_unavailable' }
  | { kind: 'limited_write'; allowedLocalDrafts: string[] }
  | { kind: 'login_unavailable'; existingSessionUsable: boolean }
  | { kind: 'sync_required'; pendingIntentCount: number }
  | { kind: 'blocked'; reason: string };

Render:

function DegradedBanner({ mode }: { mode: DegradedMode }) {
  switch (mode.kind) {
    case 'read_only':
      return <Banner tone="warning">Read-only mode. Actions are disabled until access can be verified.</Banner>;
    case 'login_unavailable':
      return <Banner tone="critical">Sign-in is temporarily unavailable.</Banner>;
    case 'sync_required':
      return <Banner tone="info">{mode.pendingIntentCount} offline changes need review before sync.</Banner>;
    default:
      return null;
  }
}

Avoid saying:

You are offline, but keep working as normal.

That is rarely true for authorization-sensitive apps.


13. Offline form strategy

For forms, separate draft from submit.

Offline allowed:

fill form locally
save draft locally
attach local-only pending file reference
validate simple client constraints

Online required:

server validation
authorization
workflow transition
file upload finalization
submission audit
case state change
notifications

Draft schema:

type OfflineDraft<T> = {
  draftId: string;
  tenantId: string;
  resourceId?: string;
  formType: string;
  data: T;
  createdAt: string;
  updatedAt: string;
  baseResourceVersion?: number;
  containsSensitiveData: boolean;
  encryptionStatus: 'none' | 'device' | 'app-managed';
};

Submit button behavior:

Offline: Save draft only
Online: Submit to server after fresh auth/permission/resource checks

This preserves user work without pretending the domain mutation occurred.


14. Offline sensitive data storage

Do not store sensitive data offline by default.

Questions before caching sensitive authenticated data:

Is offline read required by product?
Is the data PII, evidence, financial, regulated, or legally sensitive?
Should this be encrypted at rest?
Can the user revoke device access?
Can remote logout clear it?
Can logout clear service worker/cache/indexeddb?
Can data expire automatically?
Can another OS user access the browser profile?
Is session replay/analytics excluded from this data?

Browser storage is not a secure vault.

Offline sensitive data needs its own risk acceptance.


15. Tenant isolation in offline cache

Offline cache must be tenant-scoped.

Bad key:

case:CAS-1001

Better:

tenant:ORG-7:case:CAS-1001:version:17

Cache metadata:

type TenantScopedCacheEntry<T> = {
  tenantId: string;
  subjectId: string;
  authEpoch: number;
  permissionEpoch: number;
  resourceKey: string;
  data: T;
};

On tenant switch:

hide previous tenant offline cache
cancel previous tenant queue processing
revalidate session membership
clear visible query cache

Never show cached tenant A data while tenant B is active.


16. Reconnect reconciliation

Reconnect is a protocol.

Do not simply refetch everything and hope.

Use a staged process:

1. Detect API reachability.
2. Restore/validate session.
3. Validate tenant membership.
4. Refresh permission projection.
5. Refresh critical resource versions.
6. Reconcile cached reads.
7. Revalidate offline intents.
8. Sync allowed intents.
9. Resolve conflicts.
10. Clear expired/denied intents.

Reconnect is not just network recovery.

It is security state recovery.


17. Conflict resolution

Offline work often conflicts with server state.

Example:

User edits enforcement recommendation offline based on version 12.
Supervisor modifies recommendation online to version 13.
User reconnects and submits version 12 edit.

Server returns:

{
  "kind": "CONFLICT",
  "resourceId": "case:CAS-1001",
  "baseVersion": 12,
  "currentVersion": 13,
  "mergeStrategy": "manual_review_required"
}

UI options:

view your draft
view current server version
copy selected fields
submit new change request
cancel draft

For authorization-sensitive systems, do not auto-merge protected fields unless the server explicitly supports it.


18. Session expiry while offline

If the session expires offline, the app may not know immediately.

Therefore offline UI should avoid saying:

You are signed in

Better:

Last signed in as Maya. Access will be verified when reconnected.

Session projection:

type OfflineSessionProjection = {
  subjectDisplayName: string;
  tenantName: string;
  lastVerifiedAt: string;
  expiresAt?: string;
  verificationState: 'fresh' | 'stale' | 'unknown';
};

When offline and past expiry:

read-only cache may remain visible only if policy allows
all protected mutations disabled
queued intents wait for reauthentication

19. Logout and offline state

Logout must clear offline-authenticated state as much as possible.

On logout:

clear React memory
clear query cache
clear offline queue or mark orphaned according to policy
clear IndexedDB sensitive entries
clear service worker caches for authenticated APIs
clear session projection
broadcast logout to other tabs
ask server to revoke session when network available

If logout happens while offline:

local logout still takes effect immediately
server revocation becomes pending until online
protected cached data should be hidden/cleared according to policy

Do not keep displaying cached sensitive data after local logout.


20. Degraded policy engine

If policy evaluation is unavailable, do not let the frontend decide policy locally.

Possible modes:

fail closed for all protected mutations
allow already-open read-only session for low-risk cached data
show actions disabled with "access cannot be verified"
permit only emergency break-glass flow if explicitly designed and audited

Break-glass is not:

if policy service down, allow admins

Break-glass requires:

explicit user action
reason capture
narrow scope
short expiry
server-side audit
after-action review
possible supervisor approval

21. Practical implementation skeleton

Auth-aware offline manager:

type OfflinePolicy = {
  canReadCached(resourceType: string, sensitivity: string): boolean;
  canQueueIntent(action: string): boolean;
  requiresForegroundConfirmation(action: string): boolean;
};

class OfflineAuthCoordinator {
  constructor(
    private readonly authStore: AuthStore,
    private readonly permissionClient: PermissionClient,
    private readonly queue: OfflineIntentQueue,
    private readonly api: ApiClient,
    private readonly policy: OfflinePolicy,
  ) {}

  async onReconnect() {
    const session = await this.authStore.restoreSession();

    if (!session.authenticated) {
      await this.queue.pauseAll('SESSION_REQUIRED');
      return;
    }

    const permissions = await this.permissionClient.refresh({
      tenantId: session.tenantId,
    });

    const intents = await this.queue.listPending({ tenantId: session.tenantId });

    for (const intent of intents) {
      if (!this.policy.canQueueIntent(intent.action)) {
        await this.queue.markDenied(intent.intentId, 'OFFLINE_SYNC_NOT_ALLOWED');
        continue;
      }

      const decision = await this.api.revalidateIntent(intent);

      if (decision.kind === 'DENY') {
        await this.queue.markDenied(intent.intentId, decision.reasonCode);
        continue;
      }

      if (decision.kind === 'CONFLICT') {
        await this.queue.markConflicted(intent.intentId, decision.currentVersion);
        continue;
      }

      if (decision.kind === 'STEP_UP_REQUIRED') {
        await this.queue.markNeedsUserAction(intent.intentId, 'STEP_UP_REQUIRED');
        continue;
      }

      const result = await this.api.submitIntent(intent);
      await this.queue.markCommitted(intent.intentId, result.resourceVersion);
    }
  }
}

Notice the order:

restore session -> refresh permissions -> list intents -> revalidate each intent -> submit

Not:

list intents -> submit everything

22. Offline UX patterns

Good offline UX is honest.

Use clear states:

Read-only offline mode
This data was last updated 12 minutes ago
Actions will be available after access is verified
3 drafts saved locally
2 changes require review before sync
This action requires online verification

Bad offline UX:

No warning
Buttons appear enabled but silently fail
Cached data looks fresh
Queue auto-submits high-risk actions without user awareness

For serious systems, offline mode should feel controlled, not magical.


23. Observability

Track:

offline_entered
offline_exited
auth_degraded_entered
auth_degraded_exited
offline_cache_viewed
offline_intent_created
offline_intent_revalidated
offline_intent_committed
offline_intent_denied
offline_intent_conflicted
offline_intent_expired
service_worker_cache_blocked_authenticated_api
stale_permission_snapshot_used_for_display

Never log sensitive payloads from offline drafts.

Use correlation IDs for sync:

intentId
idempotencyKey
correlationId
actorId
tenantId
resourceId
action
outcome

24. Testing matrix

Test:

open app online -> go offline -> cached read allowed
open app online -> go offline -> sensitive cached read blocked
create offline draft -> reconnect -> session valid -> permission allowed -> commit
create offline draft -> reconnect -> permission revoked -> denied
create offline draft -> reconnect -> resource version conflict -> conflict UI
queue action -> logout offline -> reconnect -> no replay
queue action in tenant A -> switch tenant B -> no cache leakage
service worker cache-first bug -> authenticated API blocked from cache-first strategy
permission endpoint degraded -> protected mutations disabled
IdP degraded -> existing session behavior matches policy
background sync attempts protected POST -> revalidation required
session expired while offline -> queued intents pause for login

Use browser automation plus fake network toggles.

Do not test only happy-path reconnect.

Offline auth bugs usually live in recovery paths.


25. Anti-pattern catalog

Anti-pattern 1: generic offline POST replay

All failed POST requests are saved and replayed later.

Fix:

Only allow explicit action types. Revalidate session/permission/resource before sync.

Anti-pattern 2: cache-first authenticated API

Service worker cache-first for /api/*.

Fix:

Network-only for session, permissions, mutation, sensitive endpoints.

Anti-pattern 3: stale permission enables button

Button remains enabled offline because old permission said allowed.

Fix:

Show stale permission state. Disable protected mutation until revalidated.

Anti-pattern 4: offline admin grant

Admin can grant access offline and sync later.

Fix:

Access expansion requires online server-side authorization and audit.

Anti-pattern 5: logout does not clear offline cache

User logs out but browser back/offline cache still shows sensitive data.

Fix:

Local logout clears/hides sensitive offline storage and query cache immediately.

26. Production checklist

Before shipping offline/degraded auth, answer:

Which resources are offline-readable?
Which actions can create offline drafts?
Which actions can be queued as intent?
Which actions are blocked offline?
What is the permission TTL?
What is the session projection TTL?
What happens when session expires offline?
What happens when permission changes before reconnect?
What happens when tenant changes before reconnect?
What service worker routes are network-only?
What cache keys include tenant/user/auth epoch?
What data is cleared on logout?
What sync operations require foreground confirmation?
What sync operations require step-up auth?
How are conflicts represented?
How are denied offline intents explained?
How are offline drafts protected at rest?
What telemetry/audit events exist?

If this checklist is unanswered, offline support is not ready.


27. Final mental model

Offline support is not a product toggle.

It is a security model.

The app may cache data.

The app may save drafts.

The app may preserve intent.

But protected side effects require fresh authority.

Offline state remembers.
Authorization decides.
Sync reconciles.

That separation is the difference between a convenient offline app and a hidden access-control bypass.


28. Phase 7 closing note

Phase 7 covered authenticated data movement:

API client auth boundary
TanStack Query integration
GraphQL auth
REST BOLA/IDOR defense
file upload/download authorization
WebSocket/SSE auth
background jobs and polling auth
cache-control for authenticated UI
optimistic UI with authorization
offline and degraded auth

The theme is consistent:

Data fetching is not just data fetching once authentication and authorization exist.

Every transport, cache, queue, stream, retry, optimistic patch, and offline draft becomes part of the auth surface.

Phase 8 will move into SSR, React Server Components, Next.js, BFF, and edge runtime boundaries.


References

Lesson Recap

You just completed lesson 70 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.