elsa-workflows/elsa-core · error · InvalidOperationException
The external refresh token is invalid.
Error message
The external refresh token is invalid.
What it means
DefaultExternalAuthenticationTokenIssuer.RefreshAsync validates the external refresh token format and that it maps to an existing session belonging to the given clientId. It throws when the token lacks the sessionId.hash structure, the session no longer exists, or the session's AuthenticationClientId does not match the presented clientId — typically because refresh rotation already revoked the session after reuse of an older token.
Solutions
- Use the latest issued refresh token; after rotation the previous one is revoked and must be discarded.
- Pass the same clientId that originally obtained the token to RefreshAsync.
- Verify the stored token is complete and untruncated (format: '<sessionId>.<hash>').
- If the session was rotated/revoked, re-authenticate the user to obtain a fresh token pair.
- Persist the refreshed token atomically immediately after each refresh to avoid reuse races.
Example fix
// before
await issuer.RefreshAsync("other-client", staleRefreshToken); // rotated token or wrong clientId
// after
await issuer.RefreshAsync(originalClientId, latestRefreshToken); // current, unrotated token Defensive patterns
Strategy: try-catch
Validate before calling
// Before calling RefreshAsync, sanity-check the token shape and clientId
bool LooksLikeRefreshToken(string token) =>
!string.IsNullOrEmpty(token) && token.IndexOf('.') is > 0 and var i && i < token.Length - 1; Type guard
bool IsValidRefreshTokenFormat(string? token) =>
!string.IsNullOrEmpty(token)
&& token.IndexOf('.') is var i && i > 0 && i < token.Length - 1; Try / catch
try
{
var response = await issuer.RefreshAsync(clientId, refreshToken, ct);
// persist the new refresh token atomically here
}
catch (InvalidOperationException ex) when (ex.Message.Contains("refresh token is invalid"))
{
// treat as session revoked: clear stored token and re-authenticate the user
await tokenStore.ClearAsync(userId);
await signInManager.ChallengeAsync();
} Prevention
- Persist rotated refresh tokens atomically immediately after refresh to prevent reuse races.
- Never reuse a refresh token once a refresh response has been received.
- Always use the clientId that originally requested the token.
- Handle this error by forcing re-authentication, not by retrying the same token.
When it happens
Trigger: Thrown from RefreshAsync when: (1) the raw token has no '.' or it is at position 0 or last (malformed format); (2) sessionStore.FindByIdAsync returns null (session deleted/rotated away); (3) session.AuthenticationClientId differs case-sensitively from the clientId argument.
Common situations: Two clients/processes racing to refresh and the second reusing the already-rotated token; stale refresh token persisted by a client across restarts; wrong clientId passed to the refresh call; corrupted or truncated token from storage; test 'RefreshRotationRevokesTheSessionWhenAnOlderTokenIsReused' exercises exactly this reuse-revocation path.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- The identity provider rejected the authentication request.
- The identity provider callback did not contain an…
- The provider client secret is unavailable.
- The external authentication session user no longer exists.
- 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/9dfc161c123a4cdd.
Report an issue: GitHub.
Appendix: source
Thrown at src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs:39
IElsaTokenService tokenService,
ITenantAccessor tenantAccessor,
ISystemClock clock,
IOptions<ExternalAuthenticationOptions> options) : IExternalAuthenticationTokenIssuer
{
public async ValueTask<ExternalTokenResponse> IssueAsync(ExternalAuthenticationSession session, CancellationToken cancellationToken = default)
{
var refreshToken = CreateRefreshToken(session.Id);
session.CurrentRefreshTokenHash = Hash(refreshToken);
await sessionStore.SaveAsync(session, cancellationToken);
return await IssueResponseAsync(session, refreshToken, cancellationToken);
}
public async ValueTask<ExternalTokenResponse> RefreshAsync(string clientId, SensitiveString refreshToken, CancellationToken cancellationToken = default)
{
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);
}View on GitHub (pinned to fe9217bdfa)