Sekret — risk-free toString() of Kotlin data class?
Problem
One of Kotlin’s most appreciated features is the data class. With a single keyword, the compiler automatically generates equals, hashCode, copy, componentN, and toString — eliminating an entire category of boilerplate that Java developers had to write and maintain by hand. For domain objects, value types, and state containers, data classes are the obvious choice.
The toString function, in particular, is invaluable for debugging. When you log a data class instance, you get a complete, readable snapshot of its state: every field name and value, formatted cleanly. This is exactly what you want during development.
In production, it becomes a liability.
Consider a typical MVI (Model-View-Intent) architecture, where a view receives a single state object containing everything it needs to render:
data class ViewState(
val username: String,
val password: String,
val isButtonEnabled: Boolean
)
// PS: never keep passwords in String
Adding observability to this pattern is natural and encouraged:
fun render(viewState: ViewState) {
logger.info("render $viewState")
...
}
When logger.info calls viewState.toString(), it produces something like:
ViewState(username=james.bond, password=123456, isButtonEnabled=true)
That string now flows wherever your logger sends data — logcat, crash reporters, analytics SDKs, remote log aggregation services, third-party monitoring platforms. Every system that receives your logs now receives your users’ passwords.
This is not a hypothetical risk. It is a concrete, reproducible data pipeline from your application to third-party servers, with your users’ sensitive data flowing through it on every state change.
The Scope of the Problem
The data leakage vector extends well beyond Android’s MVI pattern. Any architecture that uses data classes as data containers and logs those containers is affected:
- Crash reporters (Crashlytics, Sentry, Bugsnag) allow you to attach context objects to crash reports. If that context is a data class, its full contents — including sensitive fields — appear in the crash report.
- Analytics pipelines frequently serialize events and their associated state for processing. Data classes are natural event payloads.
- Microservice architectures often log all incoming and outgoing messages for observability. If your message types are data classes, every request and response is fully exposed in your log infrastructure.
- Repository pattern with sealed class results:
Result.Success(data = userProfile)— logging the result logs the entire user profile. - State machines logging every state transition: each logged state includes every field.
The common thread: modern logging practices encourage comprehensive observability. Data classes make it trivial to log anything. The intersection of the two creates a systematic PII (Personally Identifiable Information) exfiltration risk.
Regulatory Consequences
This is not just a best-practice concern. Under GDPR (General Data Protection Regulation) in the European Union, logging personal data — names, email addresses, passwords, health information, financial data — without a legitimate legal basis and appropriate technical controls can result in fines of up to €20 million or 4% of global annual revenue, whichever is higher. CCPA (California Consumer Privacy Act) carries similar obligations in the US. PCI DSS requirements explicitly prohibit logging cardholder data.
The challenge is that these leaks are silent. There is no exception, no warning, no test failure. The data simply flows into log storage, where it may sit unnoticed for months or years.
Existing Workarounds and Their Limitations
There are several manual approaches to excluding sensitive fields from toString. Each requires the developer to actively remember to protect every sensitive field, in every class, every time a class is modified.
Overriding toString entirely is the simplest approach but introduces a maintenance burden: every new property addition requires manually updating the override. Miss one update after a refactor and you have reintroduced the leak.
Wrapper types (Secret<T>) are more principled but require changing the field type, which affects the API surface, copy() calls, and any serialization configuration.
Defining fields outside the primary constructor removes them from all data class semantics, but forces mutability and changes equality semantics — two Data instances with different passwords compare as equal.
All of these approaches share a fundamental weakness: they are opt-in. The default behavior leaks. A developer who does not know about the risk, or forgets to apply the protection to a new field, produces a vulnerable class with no warning from the compiler.
Solution: A Compiler Plugin
Sekret is a Kotlin compiler plugin that modifies the generated toString bytecode for annotated fields during compilation. Instead of writing manual overrides or restructuring your data classes, you annotate individual sensitive fields with @Secret:
data class Data(
val username: String,
@Secret val password: String
)
print(Data("james.bond", "123456"))
// prints out
// Data(username=james.bond, password=■■■)
The password field is replaced with a redaction marker (■■■) in the output. Every other field continues to appear normally. The data class retains all its semantic properties — equals, hashCode, copy, and componentN all include the password field as expected. Only toString is modified, and only for annotated fields.
How the Compiler Plugin Works
Building a Kotlin compiler plugin requires working at a level most Kotlin developers never encounter. The Kotlin compilation pipeline processes source code through several stages: parsing into a PSI (Program Structure Interface) tree, type-checking and resolution, translation to an IR (Intermediate Representation) tree, and finally code generation to JVM bytecode.
Sekret hooks into the IR stage. When the compiler generates the IR for a data class toString method, the plugin inspects the fields included in the generated implementation, identifies those annotated with @Secret, and rewrites the IR nodes responsible for appending those field values — replacing the value reference with a constant redaction string.
The result is identical to what you would get if you had hand-written toString to exclude the field, but it is generated automatically, stays in sync with the class definition as it evolves, and requires zero manual intervention after the initial annotation.
Critically, this is a compile-time transformation with no runtime overhead. The generated bytecode is as efficient as a hand-written toString. There is no reflection, no proxy, no wrapper object allocated per invocation.
Design Philosophy
The @Secret annotation approach is deliberately declarative and self-documenting. When a code reviewer sees a field annotated with @Secret, the intent is immediately clear: this field contains sensitive data that must not appear in logs. The annotation serves double duty as both a functional directive and inline documentation.
This is the same philosophy behind @Transient for serialization — you annotate the field to opt out of a default behavior, rather than restructuring the class. Kotlin’s standard library already uses this pattern for serialization; Sekret brings it to toString.
The protection is also future-proof. Once a field is annotated, it remains protected regardless of how the surrounding class evolves. New fields added to the class appear in toString as normal. The annotated field never does.
More info on GitHub: https://github.com/aafanasev/sekret
Perhaps JetBrains will eventually offer an out-of-box solution similar to the @Transient annotation for serialization — a standard way to exclude fields from the compiler-generated toString. Until then, Sekret fills that gap.
This article was originally posted on Medium.