Guides

Token Refresh

Silent refresh, retry policy, and forced re-authentication.

Authyra can renew an expiring access token without prompting the user, as long as the active provider opts in with supportsRefresh: true and returns a refresh token.


Enabling it

AuthyraClient(
  providers: [...],
  storage: SecureAuthStorage(),
  config: const AuthConfig(
    autoRefresh: true,          // default
    refreshBeforeExpiry: 300,   // trigger 5 minutes before expiry
  ),
)

With autoRefresh: true, two things happen automatically:

  1. Background scheduler: a periodic timer checks the active session and refreshes it proactively when it's within refreshBeforeExpiry of expiring.
  2. Read-time refresh: getSession() (and by extension getAccessToken()) refreshes inline if the cached session is expiring soon, before returning.

Disable it (autoRefresh: false) in backend or CLI contexts with no continuously running event loop, and manage renewal explicitly.


Manual refresh

final ok = await Authyra.instance.refreshSession();
if (!ok) {
  // The session has already been cleared; send the user back to login.
  await Authyra.instance.signOut();
}

Retry policy

Internally, TokenRefresher retries a transient failure (the provider's refreshToken() returns null or throws) up to maxRetries additional times (default 2) with retryDelay between attempts (default 5s) before giving up.

Not every failure is transient. TokenRefresher distinguishes:

SignalMeaningBehaviour
refreshToken() returns nullThe refresh token itself was rejected by the serverRetried, might succeed on a later attempt (clock skew, transient 5xx)
Throws RefreshDeniedExceptionThe refresh must never be retried: no refresh token, provider doesn't support refresh, or an AuthCallbacks.onBeforeTokenRefresh override denied itSession expires immediately, no retries, no delay

You won't throw RefreshDeniedException yourself in ordinary usage, AuthyraClient does it internally for the three permanent cases. It matters if you're reading TokenRefreshEvent/SessionExpiredEvent timing, or implementing your own RefreshProvider at the SessionManager level.


What happens on exhaustion

When retries are exhausted (or a RefreshDeniedException short-circuits them):

  1. The session is removed from the registry.
  2. SessionExpiredEvent is emitted on client.events.
  3. TokenRefreshEvent(success: false) is emitted.
  4. authStateChanges emits AuthState.unauthenticated().
client.events.on<SessionExpiredEvent>((e) {
  showSnackBar('Your session expired. Please sign in again.');
});

Gating refresh with a callback

Override AuthCallbacks.onBeforeTokenRefresh to check a server-side revocation list or enforce your own rate limit:

class RevocationAwareCallbacks extends AuthCallbacks {
  @override
  Future<CallbackResult> onBeforeTokenRefresh(
    AuthUser user,
    String refreshToken,
  ) async {
    if (await revocationStore.isRevoked(refreshToken)) {
      return const CallbackResult.deny('Refresh token revoked');
    }
    return const CallbackResult.allow();
  }
}

A denial here expires the session immediately (see the table above), it does not get retried.


Implementing refresh in a custom provider

class MyApiProvider implements AuthProvider {
  @override bool get supportsRefresh => true;

  @override
  Future<AuthTokenResult?> refreshToken(String refreshToken) async {
    final res = await myApi.post('/auth/refresh', body: {'refreshToken': refreshToken});
    if (res.statusCode != 200) return null; // rejected, treated as transient
    return AuthTokenResult(
      accessToken: res.data['accessToken'],
      expiresAt: DateTime.parse(res.data['expiresAt']),
      // refreshToken omitted: Authyra keeps the existing one
    );
  }
}
Copyright © 2026