elsa-workflows/elsa-core · error · InvalidOperationException

The external authentication session secrets changed.

Error message

The external authentication session secrets changed.

What it means

RefreshAsync re-computes the secret fingerprint of the connection's SecretBindings and compares it to the fingerprint captured when the session was created. This is thrown when they differ, meaning the external credentials (secrets) behind the connection were changed after sign-in and the session must not be silently continued with new credentials.

Solutions

  1. Have the user re-authenticate (fresh IssueAsync) so a new session is created against the current secret fingerprint.
  2. If the rotation was accidental, restore the original secret values in the connection's SecretBindings so the fingerprint matches the one recorded on the session.
  3. Coordinate secret rotation with users: expect all live external sessions to require re-login after rotating SecretBindings.
  4. If secrets are vault-synced, pin the fingerprint-relevant generation or stage rotation to match session expectations.

Example fix

// before: assume refresh survives secret rotation
var resp = await issuer.RefreshAsync(oldToken, clientId);

// after
try
{
    return await issuer.RefreshAsync(oldToken, clientId);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("secrets changed"))
{
    logger.LogInformation("External secrets rotated; re-authenticating user {UserId}", sessionUserId);
    return await issuer.IssueAsync(signInRequest); // fresh session against new secrets
}
Defensive patterns

Strategy: try-catch

Try / catch

try
{
    return await issuer.RefreshAsync(refreshToken, clientId);
}
catch (InvalidOperationException ex) when (ex.Message == "The external authentication session secrets changed.")
{
    // credentials rotated server-side; stale sessions cannot be continued
    ClearStoredTokens();
    return await IssueAsync(newSignInRequest); // fresh sign-in picks up new secrets
}

Prevention

When it happens

Trigger: Refreshing while connection.Connection.SecretBindings were updated/rotated so GetSecretFingerprintAsync(current bindings) != session.SecretGenerationFingerprint. Any edit to the underlying secret values referenced by the connection between IssueAsync and the refresh call.

Common situations: Ops rotated the external provider's client secret / API key and updated it in Elsa's secret bindings; secrets were re-synced from a vault with a new generation; the environment's secret store changed (dev vs prod); a re-import of the connection replaced SecretBindings.

Understand the failure class

Related errors


AI-assisted analysis of elsa-workflows/elsa-core@fe9217bdfa (2026-09-13). Data as JSON: /api/errors/4b364a94c1e1713b. Report an issue: GitHub.

Appendix: source

Thrown at src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs:49

        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);
    }

    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

View on GitHub (pinned to fe9217bdfa)