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

  1. Use the latest issued refresh token; after rotation the previous one is revoked and must be discarded.
  2. Pass the same clientId that originally obtained the token to RefreshAsync.
  3. Verify the stored token is complete and untruncated (format: '<sessionId>.<hash>').
  4. If the session was rotated/revoked, re-authenticate the user to obtain a fresh token pair.
  5. 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

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

Related errors


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)