diff --git a/GitIntegration.Test/Hosting/CredentialRedactionTests.cs b/GitIntegration.Test/Hosting/CredentialRedactionTests.cs
new file mode 100644
index 0000000..83f602d
--- /dev/null
+++ b/GitIntegration.Test/Hosting/CredentialRedactionTests.cs
@@ -0,0 +1,75 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.GitIntegration.Test;
+
+using System;
+
+[TestClass]
+public sealed class CredentialRedactionTests
+{
+ private const string Secret = "gho_s3cretTokenValue";
+
+ [TestMethod]
+ public void ATokenCredentialDoesNotPrintItsToken()
+ {
+ string printed = HostingCredential.FromToken(Secret).ToString();
+
+ Assert.DoesNotContain(Secret, printed);
+ Assert.AreEqual("HostingCredential { Kind = Token, Token = ***, Username = , Password = }", printed);
+ }
+
+ [TestMethod]
+ public void ABearerCredentialDoesNotPrintItsToken()
+ {
+ string printed = HostingCredential.FromBearerToken(Secret).ToString();
+
+ Assert.DoesNotContain(Secret, printed);
+ Assert.Contains("Kind = BearerToken", printed);
+ }
+
+ [TestMethod]
+ public void AUsernamePasswordCredentialPrintsTheUsernameButNotThePassword()
+ {
+ HostingCredential credential = new()
+ {
+ Kind = HostingCredentialKind.UsernamePassword,
+ Username = "octocat",
+ Password = Secret,
+ };
+
+ string printed = credential.ToString();
+
+ Assert.DoesNotContain(Secret, printed);
+ Assert.AreEqual("HostingCredential { Kind = UsernamePassword, Token = , Username = octocat, Password = *** }", printed);
+ }
+
+ [TestMethod]
+ public void InterpolatingACredentialDoesNotLeakItsToken()
+ {
+ HostingCredential credential = HostingCredential.FromToken(Secret);
+
+ string message = $"Signed in: {credential}";
+
+ Assert.DoesNotContain(Secret, message);
+ }
+
+ [TestMethod]
+ public void ADeviceCodePrintsTheUserCodeButNotTheDeviceCode()
+ {
+ GitHubDeviceCode code = new()
+ {
+ UserCode = "WXYZ-1234",
+ DeviceCode = Secret,
+ VerificationUri = new Uri("https://github.com/login/device"),
+ ExpiresIn = TimeSpan.FromSeconds(900),
+ Interval = TimeSpan.FromSeconds(5),
+ };
+
+ string printed = code.ToString();
+
+ Assert.DoesNotContain(Secret, printed);
+ Assert.Contains("UserCode = WXYZ-1234", printed);
+ Assert.Contains("DeviceCode = ***", printed);
+ Assert.Contains("VerificationUri = https://github.com/login/device", printed);
+ }
+}
diff --git a/GitIntegration/GitProvider.cs b/GitIntegration/GitProvider.cs
index efc29c9..aae19b3 100644
--- a/GitIntegration/GitProvider.cs
+++ b/GitIntegration/GitProvider.cs
@@ -585,6 +585,16 @@ public sealed record HostingCredential
/// Gets the password, when is .
public string? Password { get; init; }
+ /// Returns the record's members with and redacted.
+ ///
+ /// The compiler-generated version prints every property, so logging or interpolating a
+ /// credential wrote a live token in plain text. A secret prints as *** when present and
+ /// blank when absent, which keeps "is one set" visible for diagnostics without the value.
+ ///
+ /// The credential as the compiler would print it, minus the secrets.
+ public override string ToString() =>
+ $"{nameof(HostingCredential)} {{ Kind = {Kind}, Token = {Redacted.Of(Token)}, Username = {Username}, Password = {Redacted.Of(Password)} }}";
+
/// Creates a result carrying a host-native token, such as a personal access token.
///
/// Not a bearer token. Azure DevOps sends this kind as Basic with an empty username, the scheme
diff --git a/GitIntegration/Hosting/GitHubDeviceFlow.cs b/GitIntegration/Hosting/GitHubDeviceFlow.cs
index 6f7b657..eac87a0 100644
--- a/GitIntegration/Hosting/GitHubDeviceFlow.cs
+++ b/GitIntegration/Hosting/GitHubDeviceFlow.cs
@@ -39,6 +39,16 @@ public sealed record GitHubDeviceCode
/// Gets the minimum wait GitHub requires between token requests.
public required TimeSpan Interval { get; init; }
+
+ /// Returns the record's members with redacted.
+ ///
+ /// The device code is what the token poll exchanges for a credential, so it is as sensitive as
+ /// the token while it is valid. stays visible: it is shown to the user
+ /// anyway, and it is what a log needs to match a sign-in attempt.
+ ///
+ /// The device code as the compiler would print it, minus the secret.
+ public override string ToString() =>
+ $"{nameof(GitHubDeviceCode)} {{ UserCode = {UserCode}, DeviceCode = {Redacted.Of(DeviceCode)}, VerificationUri = {VerificationUri}, ExpiresIn = {ExpiresIn}, Interval = {Interval} }}";
}
///
diff --git a/GitIntegration/Hosting/Redacted.cs b/GitIntegration/Hosting/Redacted.cs
new file mode 100644
index 0000000..6cdd032
--- /dev/null
+++ b/GitIntegration/Hosting/Redacted.cs
@@ -0,0 +1,20 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.GitIntegration;
+
+///
+/// The placeholder a record's ToString prints in place of a secret.
+///
+internal static class Redacted
+{
+ /// The text printed for a secret that is present.
+ internal const string Placeholder = "***";
+
+ ///
+ /// Returns for a present secret and an empty string for an absent one,
+ /// matching how a record prints a member.
+ ///
+ /// The secret, or .
+ /// The text to print in the secret's place.
+ internal static string Of(string? secret) => secret is null ? string.Empty : Placeholder;
+}