In one of my recent Flutter projects, I had to implement a session token mechanism that behaved very differently from standard JWT-based authentication systems.
The backend issued a 15-minute session token, but with strict constraints:
- No expiry timestamp was provided
- The server extended the session only when the app made an API call
- Long-running user workflows depended entirely on session continuity
If the session expired unexpectedly, users could lose progress mid-flow, leading to inconsistent states and broken experiences. This meant the entire token lifecycle had to be controlled on the client, in a predictable and self-healing way.
This is the architecture I designed.
- The Core Challenge
The server provided the token but not its expiry. The only rule:
“Token is valid for 15 minutes, and any API call extends the session.”
To protect long-running user interactions, the application needed to:
- Track token lifespan locally
- Refresh or extend sessions automatically
- Work uniformly across REST and GraphQL
- Survive app backgrounding and resuming
- Preserve in-progress workflows without UI disruption
This required a fully client-driven token lifecycle engine.
- Client-Side Countdown Timer
Since expiry data was not available from the server, I implemented a local countdown timer to represent session validity.
How it works:
- When token is obtained → start a 15-minute timer
- When any API call happens → reset the timer (because backend extends session)
- If the timer is about to expire:
- Active user flow → show a visible countdown
- Passive or static screens → attempt silent refresh
- If refresh fails → gracefully log out in case of logged-in users
This timer became the foundation of the entire system.

- Handling App Lifecycle Transitions
Users frequently minimize or switch apps. To maintain session correctness:
- On background: pause the timer and store timestamp
- On resume: calculate elapsed background time
- If still valid → refresh & restart timer
- If expired → re-authenticate or log out
This prevented accidental session expiry just because the app was minimized.

- REST Auto-Refresh with Dio Interceptors
For REST APIs, Dio interceptors provided a clean, centralized way to manage token refresh.
Interceptor Responsibilities:
- If timer is null → start timer
- If timer exists but is inactive,
- token expired → refresh token
- perform silent re-login if needed
- If timer is active → reset the timer
- Inject updated token into headers
Conceptual Implementation:
class SessionInterceptor extends Interceptor {
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (sessionTimer == null) {
startSessionTimer();
} else if (!sessionTimer.isActive) {
await refreshSession();
if (isAuthenticatedUser) {
await silentReauthentication();
}
}
options.headers[‘Authorization’] = ‘Bearer $currentToken’;
resetSessionTimer();
handler.next(options);
}
}
This made REST calls self-healing, with no manual checks in individual services.
- GraphQL Auto-Refresh with Custom AuthLink
GraphQL required custom handling because it doesn’t support interceptors.
I implemented a custom AuthLink where token management happened inside getToken().
AuthLink Responsibilities:
- Timer null → start
- Timer inactive,
- refresh token
- update storage
- silently re-login if necessary
- Timer active → reset timer and continue
GraphQL operations then behaved consistently with REST, including auto-refresh and retry.
Conceptual implementation:
class CustomAuthLink extends AuthLink {
CustomAuthLink()
: super(
getToken: () async {
if (sessionTimer == null) {
startSessionTimer();
return currentToken;
}
if (!sessionTimer.isActive) {
await refreshSession();
if (isAuthenticatedUser) {
await silentReauthentication();
}
return currentToken;
}
resetSessionTimer();
return currentToken;
},
);
}
- Silent Session Extension for Authenticated Users
When authenticated users’ sessions extended:
- token refresh happened in background
- user data was re-synced silently
- no screens were reset
- no interruptions were shown
This was essential for long-running user workflows.
Engineering Lessons Learned
- When token expiry information is not provided by the backend, session management must be treated as a first-class client responsibility rather than an auxiliary concern. Deferring this logic to individual API calls or UI layers leads to fragmentation and unpredictable behavior.
- A client-side timer, when treated as the authoritative representation of session validity, significantly simplifies the overall design. By anchoring all refresh, retry, and termination decisions to a single timing mechanism, the system becomes easier to reason about, test, and maintain.
- Application lifecycle events have a direct and often underestimated impact on session correctness. Explicitly handling backgrounding and resumption prevents sessions from expiring due to inactivity that does not reflect actual user intent or engagement.
- Centralizing session logic for REST interactions through a global interceptor reduces duplication and eliminates inconsistent implementations across services. This approach ensures that every network call adheres to the same session rules without requiring feature-level awareness.
- GraphQL requires a different integration point, but achieving behavioral parity with REST is essential. Embedding session handling within a custom authorization link proved to be the most reliable way to enforce consistent session behavior across both communication models.
- Silent session extension for authenticated users is critical for preserving continuity during long-running interactions. Refreshing sessions transparently avoids unnecessary interruptions and prevents loss of in-progress work.
- In systems where backend constraints limit visibility into session expiry, a client-driven lifecycle model is not merely a workaround. It is a necessary architectural decision that improves reliability, protects user progress, and provides predictable behavior under real-world usage conditions.
