Context
PR #925 wires the per-request backend JWT into apiFactory. buildAuthJwt derives iat/exp from the device clock (Date.now()), and freighter-backend-v2 validates timing with:
MaxTokenLifetime = 15s (rejects exp - iat > 15s)
ClockSkewLeeway = 5s (applied to the future-iat bound, the future-exp bound, and the WithLeeway expiry check)
Net client-clock tolerance is asymmetric: ~+5s ahead (future-iat check) / ~−20s behind (15s lifetime + 5s leeway). A device clock off beyond that fails all authenticated V2 requests while the wallet is unlocked, and the 401-retry re-signs from the same bad clock so it fails identically. (Reported by @aristidesstaffieri on #925.)
Current behavior (#925)
None — #925 matches the extension's authedFetch: retry once on 401, then the 401 surfaces to the caller. A skewed-clock user's authenticated V2 requests fail while the wallet is unlocked (they still work while locked, since locked requests are anonymous — which makes it confusing to debug from reports). We considered a client-side anonymous fallback but dropped it: it broke parity with the extension and masked genuine auth failures behind a permissive-backend success. This issue tracks the real fix.
Signal
Backend 401 / ExpiredTokenError / ReasonBadTiming rate on the v2 auth middleware (and/or mobile Sentry ApiError{status:401} on v2 calls). If a meaningful cohort is failing on timing, act.
Decision tree
- Small, common skew → widen the backend
ClockSkewLeeway (one constant in freighter-backend-v2 internal/auth/claims.go). Use ClockSkewLeeway, NOT MaxTokenLifetime — leeway feeds all three timing bounds so it absorbs skew both directions, whereas raising the lifetime only helps behind-skew and lets clients mint longer-lived (more-replayable) tokens. Cost: the replay window widens ~linearly with leeway; acceptable up to tens of seconds since tokens are bound to sub+methodAndPath+bodyHash. This is the same lever available to the extension, so it fixes both clients at once.
- Long tail of large skew (can't leeway past minutes without gutting replay defense) → client-side clock sync: capture the offset from a response
Date header (or a dedicated time endpoint) and sign iat/exp in server time. (Precedent: AWS SDK clock-skew correction reads Date off the error response and retries.) Would apply to both mobile and the extension.
Acceptance
- Decide 1 vs 2 from real 401/timing-rejection data.
- If (1): PR to widen
ClockSkewLeeway + note the replay-window trade in the design doc.
- If (2): client
Date/time-sync (mobile + extension).
Refs: PR #925, review thread #925 (comment)
Context
PR #925 wires the per-request backend JWT into
apiFactory.buildAuthJwtderivesiat/expfrom the device clock (Date.now()), and freighter-backend-v2 validates timing with:MaxTokenLifetime = 15s(rejectsexp - iat > 15s)ClockSkewLeeway = 5s(applied to the future-iatbound, the future-expbound, and theWithLeewayexpiry check)Net client-clock tolerance is asymmetric: ~+5s ahead (future-
iatcheck) / ~−20s behind (15s lifetime + 5s leeway). A device clock off beyond that fails all authenticated V2 requests while the wallet is unlocked, and the 401-retry re-signs from the same bad clock so it fails identically. (Reported by @aristidesstaffieri on #925.)Current behavior (#925)
None — #925 matches the extension's
authedFetch: retry once on 401, then the 401 surfaces to the caller. A skewed-clock user's authenticated V2 requests fail while the wallet is unlocked (they still work while locked, since locked requests are anonymous — which makes it confusing to debug from reports). We considered a client-side anonymous fallback but dropped it: it broke parity with the extension and masked genuine auth failures behind a permissive-backend success. This issue tracks the real fix.Signal
Backend 401 /
ExpiredTokenError/ReasonBadTimingrate on the v2 auth middleware (and/or mobile SentryApiError{status:401}on v2 calls). If a meaningful cohort is failing on timing, act.Decision tree
ClockSkewLeeway(one constant infreighter-backend-v2internal/auth/claims.go). UseClockSkewLeeway, NOTMaxTokenLifetime— leeway feeds all three timing bounds so it absorbs skew both directions, whereas raising the lifetime only helps behind-skew and lets clients mint longer-lived (more-replayable) tokens. Cost: the replay window widens ~linearly with leeway; acceptable up to tens of seconds since tokens are bound tosub+methodAndPath+bodyHash. This is the same lever available to the extension, so it fixes both clients at once.Dateheader (or a dedicated time endpoint) and signiat/expin server time. (Precedent: AWS SDK clock-skew correction readsDateoff the error response and retries.) Would apply to both mobile and the extension.Acceptance
ClockSkewLeeway+ note the replay-window trade in the design doc.Date/time-sync (mobile + extension).Refs: PR #925, review thread #925 (comment)