{"record":{"id":"4b364a94c1e1713b","repo":"elsa-workflows/elsa-core","slug":"the-external-authentication-session-secrets-changed","errorCode":null,"errorMessage":"The external authentication session secrets changed.","messagePattern":"The external authentication session secrets changed\\.","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs","lineNumber":49,"sourceCode":"        return await IssueResponseAsync(session, refreshToken, cancellationToken);\n    }\n\n    public async ValueTask<ExternalTokenResponse> RefreshAsync(string clientId, SensitiveString refreshToken, CancellationToken cancellationToken = default)\n    {\n        var rawToken = refreshToken.Reveal();\n        var separator = rawToken.IndexOf('.', StringComparison.Ordinal);\n        if (separator <= 0 || separator == rawToken.Length - 1)\n            throw new InvalidOperationException(\"The external refresh token is invalid.\");\n        var sessionId = rawToken[..separator];\n        var currentHash = Hash(rawToken);\n        var session = await sessionStore.FindByIdAsync(sessionId, cancellationToken);\n        if (session is null || !string.Equals(session.AuthenticationClientId, clientId, StringComparison.Ordinal))\n            throw new InvalidOperationException(\"The external refresh token is invalid.\");\n        var connection = await connectionRegistry.FindByKeyAsync(session.TenantId, session.ConnectionKey, cancellationToken);\n        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))\n            throw new InvalidOperationException(\"The external authentication session is no longer valid.\");\n        if (!string.Equals(session.SecretGenerationFingerprint, await GetSecretFingerprintAsync(connection.Connection.SecretBindings, cancellationToken), StringComparison.Ordinal))\n            throw new InvalidOperationException(\"The external authentication session secrets changed.\");\n\n        var nextToken = CreateRefreshToken(session.Id);\n        var rotation = await sessionStore.TryRotateRefreshTokenAsync(session.Id, currentHash, session.RefreshGeneration, Hash(nextToken), clock.UtcNow, cancellationToken);\n        if (rotation is not ExternalAuthenticationSessionRotationResult.Rotated { Session: var rotated })\n            throw new InvalidOperationException(\"The external refresh token cannot be used.\");\n\n        return await IssueResponseAsync(rotated, nextToken, cancellationToken);\n    }\n\n    private async ValueTask<ExternalTokenResponse> IssueResponseAsync(ExternalAuthenticationSession session, string refreshToken, CancellationToken cancellationToken)\n    {\n        using var tenantContext = tenantAccessor.PushContext(new()\n            { Id = session.TenantId, Name = session.TenantId });\n        var user = await userProvider.FindAsync(new()\n                       { Id = session.UserId }, cancellationToken)\n            ?? throw new InvalidOperationException(\"The external authentication session user no longer exists.\");\n        var roles = (await roleProvider.FindByIdsAsync(user.Roles, cancellationToken)).ToArray();\n        // Role permissions go through the same deployment boundary as the external grants beside them. They","sourceCodeStart":31,"sourceCodeEnd":67,"githubUrl":"https://github.com/elsa-workflows/elsa-core/blob/fe9217bdfa0e27f0e09e45006eb6898f616e513d/src/modules/Elsa.ExternalAuthentication/Services/DefaultExternalAuthenticationTokenIssuer.cs#L31-L67","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: assume refresh survives secret rotation\nvar resp = await issuer.RefreshAsync(oldToken, clientId);\n\n// after\ntry\n{\n    return await issuer.RefreshAsync(oldToken, clientId);\n}\ncatch (InvalidOperationException ex) when (ex.Message.Contains(\"secrets changed\"))\n{\n    logger.LogInformation(\"External secrets rotated; re-authenticating user {UserId}\", sessionUserId);\n    return await issuer.IssueAsync(signInRequest); // fresh session against new secrets\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"try\n{\n    return await issuer.RefreshAsync(refreshToken, clientId);\n}\ncatch (InvalidOperationException ex) when (ex.Message == \"The external authentication session secrets changed.\")\n{\n    // credentials rotated server-side; stale sessions cannot be continued\n    ClearStoredTokens();\n    return await IssueAsync(newSignInRequest); // fresh sign-in picks up new secrets\n}","preventionTips":["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."],"tags":["authentication","secrets-rotation","token-refresh"],"backgroundTag":"missing-credentials","analyzedSha":"fe9217bdfa0e27f0e09e45006eb6898f616e513d","analyzedAt":"2026-09-13T20:32:34.702Z","contentChangedAt":"2026-09-13T20:32:34.702Z","schemaVersion":2},"datasetVersion":"2026-09-16T04:17:20.429Z"}