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
- Have the user re-authenticate (fresh IssueAsync) so a new session is created against the current secret fingerprint.
- 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.
- Coordinate secret rotation with users: expect all live external sessions to require re-login after rotating SecretBindings.
- 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
- Plan secret rotation as a re-login event; communicate it to users of external connections.
- Keep SecretBindings stable for the lifetime of active sessions; stage rotations during low-traffic windows.
- If secrets come from a vault, pin generations so unrelated syncs do not change the fingerprint.
- Monitor this error as a signal that credentials changed while sessions were live.
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
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- The external authentication session is no longer valid.
- The external refresh token cannot be used.
- 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/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. TheyView on GitHub (pinned to fe9217bdfa)