elsa-workflows/elsa-core · error · InvalidOperationException
The external refresh token cannot be used.
Error message
The external refresh token cannot be used.
What it means
RefreshAsync rotates the refresh token atomically via sessionStore.TryRotateRefreshTokenAsync, which succeeds only when the presented token's hash and the session's current RefreshGeneration match the store's state. This is thrown when the rotation fails — the presented token is not the current-generation refresh token (it was already rotated/reused), or the session row changed concurrently.
Solutions
- Persist the NEW refresh token returned by each refresh immediately and atomically; never keep using the previous token.
- Serialize refreshes per session: use a single point (e.g. one backend service or a per-session lock) so only one caller refreshes at a time.
- After this error, the session is typically revoked by rotation-replay handling — force full re-authentication via IssueAsync.
- If racing callers are unavoidable, catch this error and re-read session state; only the caller holding the current token can recover.
Example fix
// before: retry reuses stale token
async Task<Token> Refresh() => await issuer.RefreshAsync(_refreshToken, clientId); // _refreshToken may be old
// after
private readonly SemaphoreSlim _refreshLock = new(1, 1);
async Task<Token> Refresh()
{
await _refreshLock.WaitAsync();
try
{
var resp = await issuer.RefreshAsync(_refreshToken, clientId); // single-flight
_refreshToken = resp.RefreshToken; // persist new token immediately
return resp;
}
finally { _refreshLock.Release(); }
} Defensive patterns
Strategy: retry
Validate before calling
// single-flight guard so only one caller refreshes per session
private static readonly SemaphoreSlim RefreshLock = new(1, 1);
await RefreshLock.WaitAsync(cancellationToken);
try { return await issuer.RefreshAsync(currentRefreshToken, clientId); }
finally { RefreshLock.Release(); } Try / catch
try
{
return await issuer.RefreshAsync(refreshToken, clientId);
}
catch (InvalidOperationException ex) when (ex.Message == "The external refresh token cannot be used.")
{
// token was already rotated (replay or race); session is revoked
ClearStoredTokens();
return await ReAuthenticateAsync();
} Prevention
- Persist the new refresh token atomically before any other code can read the old one.
- Centralize refresh in one component per session (single-flight with a lock or dedicated auth service).
- Never retry a refresh with the same token after a failure — the token may have been consumed.
- In multi-instance deployments, share session/token state (sticky sessions or shared store) so only one instance refreshes.
When it happens
Trigger: Calling RefreshAsync with an older, already-rotated refresh token (replay after a prior refresh succeeded); two concurrent refreshes racing on the same session (one wins, the other hits a generation mismatch); store state drifted so currentHash/RefreshGeneration no longer match.
Common situations: Multiple app instances or tabs sharing one session each holding a copy of the refresh token and refreshing independently; a client retried a refresh after a network error without persisting the new token from the first attempt; test scenarios asserting replay revocation (RefreshRotationRevokesTheSessionWhenAnOlderTokenIsReused).
Related errors
- The external authentication session is no longer valid.
- The external authentication session secrets changed.
- The external authentication session user no longer exists.
- The identity provider rejected the authentication request.
- The identity provider callback could not be correlated.
AI-assisted analysis of elsa-workflows/elsa-core@fe9217bdfa (2026-09-13).
Data as JSON: /api/errors/9d828d9e1a71b8df.
Report an issue: GitHub.
Appendix: source
Thrown at src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs:54
var rawToken = refreshToken.Reveal();
var separator = rawToken.IndexOf('.', StringComparison.Ordinal);
if (separator <= 0 || separator == rawToken.Length - 1)
throw new InvalidOperationException("The external refresh token is invalid.");
var sessionId = rawToken[..separator];
var currentHash = Hash(rawToken);
var session = await sessionStore.FindByIdAsync(sessionId, cancellationToken);
if (session is null || !string.Equals(session.AuthenticationClientId, clientId, StringComparison.Ordinal))
throw new InvalidOperationException("The external refresh token is invalid.");
var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellationToken);
if (session.RevokedAt != null || session.ExpiresAt <= clock.UtcNow || connection is null || connection.IsShadowed || !connection.Connection.IsEnabled || connection.Connection.ArchivedAt is not null || !string.Equals(connection.Connection.MaterialRevision, session.ConnectionMaterialRevision, StringComparison.Ordinal))
throw new InvalidOperationException("The external authentication session is no longer valid.");
if (!string.Equals(session.SecretGenerationFingerprint, await GetSecretFingerprintAsync(connection.Connection.SecretBindings, cancellationToken), StringComparison.Ordinal))
throw new InvalidOperationException("The external authentication session secrets changed.");
var nextToken = CreateRefreshToken(session.Id);
var rotation = await sessionStore.TryRotateRefreshTokenAsync(session.Id, currentHash, session.RefreshGeneration, Hash(nextToken), clock.UtcNow, cancellationToken);
if (rotation is not ExternalAuthenticationSessionRotationResult.Rotated { Session: var rotated })
throw new InvalidOperationException("The external refresh token cannot be used.");
return await IssueResponseAsync(rotated, nextToken, cancellationToken);
}
private async ValueTask<ExternalTokenResponse> IssueResponseAsync(ExternalAuthenticationSession session, string refreshToken, CancellationToken cancellationToken)
{
using var tenantContext = tenantAccessor.PushContext(new()
{ Id = session.TenantId, Name = session.TenantId });
var user = await userProvider.FindAsync(new()
{ Id = session.UserId }, cancellationToken)
?? throw new InvalidOperationException("The external authentication session user no longer exists.");
var roles = (await roleProvider.FindByIdsAsync(user.Roles, cancellationToken)).ToArray();
// Role permissions go through the same deployment boundary as the external grants beside them. They
// used to be concatenated raw, which let a permission the boundary had just excluded during grant
// resolution reappear here from the same roles -- making the deny list unenforceable for anything a
// role happened to carry, and ElsaRolePermissionGrantSource's own filtering pointless. Re-applying it
// at issuance also picks up a boundary that changed since sign-in, because refreshing reissues.
// With no boundary configured, which is the default, every well-formed permission passes and nothingView on GitHub (pinned to fe9217bdfa)