Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614505)
- First published
Cite this article(DOI: 10.5281/zenodo.21614504)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Storing Secrets in Windows Apps - Avoiding Plaintext Configuration with DPAPI. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614504 https://comcomponent.com/en/blog/2026/03/16/000-windows-app-secret-storage-best-practices-dpapi/
- DOI (latest version)
- 10.5281/zenodo.21614504
- DOI (this version)
- 10.5281/zenodo.22217148
In the previous post, “A Minimum Security Checklist for Windows Application Development,” we drew the baseline: “do not put secrets in source code or plaintext configuration” and “on Win32 / .NET, use DPAPI / ProtectedData.”
This time we dig a bit deeper into one part of that: “using DPAPI to at least be better than plaintext.”
The target is Windows apps like these:
- WPF / WinForms / WinUI desktop apps
- C# / .NET Windows clients
- Apps tempted to store connection credentials or API tokens in local configuration files
What we cover here is a realistic design for “not leaving secrets that must be stored locally sitting in plaintext in appsettings.json.”
This is not a story about “perfect defense that beats any attacker.” Oversell it, and the discussion drifts away from any realistic conversation about security.
1. The Conclusion First
In practice, this order of thinking is the clearest.
- Do not give the client long-lived secrets in the first place
- Prefer Windows authentication, integrated authentication, interactive user login, and server-side secret management
- If local storage is truly necessary, do not store it in plaintext
- On Windows, make DPAPI /
ProtectedDatathe first candidate
- On Windows, make DPAPI /
- For ordinary desktop apps, default to
DataProtectionScope.CurrentUserLocalMachinehas quite limited use cases
- DPAPI does not protect you all the way to “the machine is fully compromised”
- Code running with the same user privileges can, fundamentally, decrypt whatever that user can decrypt
And the most important point of this article is here:
“The secret key has to be stored somewhere anyway, so isn’t plaintext the same as DPAPI from a security standpoint?”
This is half right, and the conclusion is wrong.
- Roll-your-own AES with the key placed in the same app or the same configuration is, indeed, close to plaintext
- But DPAPI moves key management onto the OS and binds the party that can decrypt to “that Windows user” or “that computer”
- As a result, how well it holds up against mishaps like a leak of the configuration file by itself, the file carried to another PC, a mis-sent attachment, a backup leak, or repository contamination changes dramatically
In other words: even if the abstract claim “the key is somewhere” makes them look the same, “who can use it, in what context, and how easily” is completely different.
Calling a key left under the doormat and a key handed over at the front desk after an identity check the same thing is a bit rough.
flowchart TB
accTitle: The key is somewhere is not enough to make them the same
accDescr: A diagram showing the central point of this article - at the abstract level of the key being somewhere, plaintext and DPAPI look identical, but who can use it, in what context, and how easily is completely different.
abs1["The key is somewhere (abstract view)"] -.->|"Looking only here"| same1["They look the same"]
who1["Who can use it, in what context, how easily"] -->|"Looking here"| diff1["Completely different"]
Figure 1: At the abstract level they look alike, but who can use the key and in what context is what separates plaintext from DPAPI.
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (21 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. Why Plaintext Configuration Is Dangerous
The reason plaintext storage is dangerous is far more mundane than cryptographic theory. In practice, leaks happen through paths like these:
- The configuration file gets committed to Git as is
- The configuration file ends up wholesale in a troubleshooting ZIP
- A support request comes with the configuration file attached
- Third parties can read it via backups or file shares
- Connection strings and tokens appear verbatim in logs
- A departed employee or another user can read the file on the same machine
Plaintext loses its secrecy the moment a third party reads it.
- Open the file, and it is over
- Copy it, and it is over
- Attach it to an email, and it is over
- Leave it in a repository, and you babysit it semi-permanently
The attacker does not even need to be sophisticated. “Opens in a text editor” is, by itself, quite weak.
flowchart TB
accTitle: Plaintext loses its secrecy the moment it is read
accDescr: A diagram showing that once a configuration file is read by a third party through mundane paths such as Git contamination, a troubleshooting ZIP, a backup leak, or an attachment, plaintext loses its secrecy.
rt1["Git contamination, troubleshooting ZIP, attachment, backup"] --> read1["The configuration file gets read"]
read1 --> end1["Secrecy is lost the moment a third party reads it"]
end1 -.-> low1["The attacker does not even need to be sophisticated"]
Figure 2: The weakness of plaintext is that secrecy is lost the moment a third party reads it through an everyday mishap path.
3. The Answer to “The Key Is Stored Somewhere Anyway, So Isn’t It the Same?”
This question is reasonable. And answering it sloppily is how security articles suddenly go vague.
The answer: yes in the sense that “a key is needed somewhere”; no in the sense that “therefore they are the same.”
3.1. What Is the Same and What Is Different
Indeed, encryption ultimately needs some root of trust. Put another way, encryption always ends up resting on some anchor of trust.
However, the security difference is determined by these three points:
- Does the app hold the key directly?
- What party is the key bound to?
- If only the file is stolen, can it be decrypted?
A rough table of the differences:
| Method | Config file is read | Only the file is carried to another PC | Read by another user on the same PC | Code running with the same user privileges |
|---|---|---|---|---|
| Plaintext | Leaks on the spot | Leaks as is | Leaks as is | Readable, of course |
| Roll-your-own crypto + key in the same config / binary | Mostly leaks | Mostly leaks | Mostly leaks | Decryptable, of course |
DPAPI + CurrentUser |
Not immediately readable from the file alone | Normally hard to decrypt | Normally hard to decrypt | Decryptable |
DPAPI + LocalMachine |
Not immediately readable from the file alone | Normally hard to decrypt anywhere but that PC | Broadly decryptable on the same PC | Decryptable |
The important point here is that DPAPI separates “can read the file” from “can use the secret.”
flowchart TB
CT["Ciphertext<br/>ends up in config files / DB columns / troubleshooting ZIPs<br/>= what can be carried away"]
subgraph WIN["What decryption needs - stays on the OS side and is not contained in the ciphertext"]
UMK["User master key<br/>when protected with CurrentUser"]
MMK["Computer master key<br/>when protected with LocalMachine"]
end
CT -->|"Protected with CurrentUser"| UMK
CT -->|"Protected with LocalMachine"| MMK
UMK --> A1["Code running as that user<br/>-> can decrypt"]
UMK --> A2["Another user on the same PC<br/>-> cannot decrypt"]
MMK --> B1["Code on the same PC<br/>-> another user can decrypt too"]
UMK --> C1["Copy only the ciphertext to another PC<br/>-> cannot decrypt"]
MMK --> C1
UMK --> C2["Move the whole roaming profile<br/>-> the key material moves with it, so it can decrypt (Section 6.5)"]
Figure 3: Only the ciphertext sits on the side that can be carried away; the master key that decryption needs stays on the OS side. The reach of LocalMachine, however, is the whole PC, so it cannot shut out another user on the same machine
With plaintext, these two are the same thing. If the file can be read, so can the secret.
But with DPAPI, at least with CurrentUser, decryption must happen:
- as that Windows user
- in that Windows context
- through the OS protection machinery
At an actual incident scene, this difference is substantial.
3.2. “But the Same User Can Still Decrypt It, Right?” - Correct
This is a point that should be written without hedging.
Code executed with the same user privileges can, fundamentally, decrypt whatever that user can decrypt.
In other words, DPAPI is not primarily aimed at situations like:
- The machine is already compromised by malware
- The attacker can execute code as that user
- The machine has been fully taken over at the administrator level
In those situations, since the app itself can decrypt, the attacker’s code can decrypt too. “But it is encrypted” is not very reassuring there.
Where DPAPI helps is mainly the “file leak / misplacement / offline exfiltration / access by another user” side.
Get this backwards and you get both:
- underestimating what it can protect and not using it
- overestimating what it cannot protect and feeling safe
Both are quietly dangerous.
flowchart TB
accTitle: Where DPAPI helps and where it does not
accDescr: A diagram drawing the line between what DPAPI covers - file leaks, misplacement, offline exfiltration, and access by another user - and what it does not cover, namely attack code running with the same user privileges and an already compromised machine.
dp2["What DPAPI covers"] -->|"Helps here"| eff1["Leaks, misplacement, another user"]
dp2 -.->|"Does not help here"| noef1["Code with the same user privileges"]
dp2 -.-> mis1["Get the line wrong and you misjudge it"]
Figure 4: Without knowing the line between where it helps and where it does not, you either underestimate it and skip it, or overestimate it and relax.
3.3. So What Is the Actual Benefit?
The benefit of DPAPI in one sentence:
“You can decouple the secret itself from the readability of the configuration file.”
For example, in mishaps like these, plaintext and DPAPI come out very differently:
- A user sent the configuration file to support
- The configuration file ended up in a troubleshooting ZIP
- Only the configuration file leaked from a backup
- It got copied onto a shared folder
- Developers could look at only ciphertext and not read the contents
These are quite realistic benefits. You can shrink the everyday blast radius without casting the attacker as a movie superhuman.
flowchart TB
accTitle: How the blast radius shrinks
accDescr: A diagram showing that even when everyday mishaps such as a mis-sent support attachment, a troubleshooting ZIP, or a backup leak push a file outside, what leaves is only ciphertext, so it does not become a leak of the secret and the blast radius shrinks.
ac1["Mis-sent attachment, troubleshooting ZIP, backup leak"] --> out3["Only the ciphertext leaves"]
out3 --> sm2["It does not have to become a leak of the secret"]
sm2 --> rad1["The everyday blast radius shrinks"]
Figure 5: You cannot prevent files from leaving, but you can change what leaves into ciphertext.
4. Why DPAPI Is Just Right
When handling locally stored secrets on Windows, the reasons DPAPI hits the practical sweet spot are as follows.
4.1. Key Management Can Be Delegated to the OS
Generate an AES key yourself, store it, set permissions on it, rotate it, think through the blast radius of a leak, and add tamper detection on top. This is heavier than it looks. And done sloppily, it usually ends with the key placed in the same location.
With DPAPI, the “how do we create the encryption key and where do we put it” problem can be removed from the app implementation.
In that sense, it is closer to the essence to see DPAPI as “an API for delegating key management to the OS,” not “an API for choosing an encryption algorithm.”
flowchart TB
accTitle: The essential way to view DPAPI
accDescr: A diagram showing that DPAPI is closer to the truth when viewed not as an API for choosing an encryption algorithm but as an API that delegates to the OS the key management question of how to create a key and where to put it.
v1["An API for choosing an algorithm"] -.->|"Not this view"| dpv1["DPAPI"]
v2["An API that delegates key management to the OS"] -->|"This view is closer to the essence"| dpv1
dpv1 --> off1["Key creation and storage can be removed from the implementation"]
Figure 6: Viewing DPAPI as the place key management is delegated to makes it clear what the app can stop owning.
4.2. The Decrypting Party Can Be Bound to a Windows User or the Computer
For an ordinary desktop app, choosing CurrentUser is often the right call.
Decryption is predicated on:
- that user being logged on
- the operation running in that user’s context
As a result, you get the property that copying just the ciphertext to another PC does not make it readily usable.
4.3. Tamper Detection Comes Along Easily
A common failure with roll-your-own crypto is declaring “we encrypted with AES, done” and forgetting tamper detection.
DPAPI also provides integrity protection over the encrypted data, so detecting when someone rewrites the ciphertext can ride on the OS-side machinery, which is a practical advantage.
flowchart TB
accTitle: The difference in tamper detection
accDescr: A diagram showing that roll-your-own crypto tends to stop at encrypting with AES and forget tamper detection, whereas DPAPI carries integrity protection over the encrypted data so that detecting a rewritten ciphertext can ride on the OS-side machinery.
diy1["Roll-your-own crypto"] -.-> forget1["Tamper detection is easily forgotten"]
dpi1["DPAPI"] --> integ1["Carries integrity protection"]
integ1 --> det2["Detecting a rewrite also rides on the OS side"]
Figure 7: Not just encryption but tamper detection too can ride on the OS-side machinery, and that is the advantage of DPAPI.
4.4. Straightforward to Use from C# / .NET
In C#, you can use System.Security.Cryptography.ProtectedData directly.
Not having to add extra libraries is a real help for Windows-only apps.
5. What DPAPI Protects, and What It Does Not
It is safer to draw this line clearly.
5.1. What Becomes Easier to Protect
DPAPI is effective at least in scenarios like these:
- Plaintext leakage of configuration files
- Files carried off to another PC
- Access by another user on the same PC (assuming
CurrentUser) - Leakage via backups or attachments
- The “oops, anyone can read this” state in development and maintenance work
5.2. What It Cannot Protect, or Protects Weakly
On the other hand, do not over-trust it in these situations:
- Attack code running with the same user privileges
- Full compromise of the machine itself
- Takeover with administrator privileges
- The plaintext in memory after the app decrypts
- Long-lived secrets distributed identically to all clients
The last one, “a long-lived secret shared by all clients,” is especially important.
For example, designs like:
- embedding the same API key for all customers
- having the same shared password on all machines
- shipping a fixed decryption key that lives entirely on the client
tend to be designs where extraction from any single machine ripples out to everything. The logic is simple: if the app can decrypt it on any single machine, that secret can be extracted.
DPAPI is effective at “making that storage location better than plaintext,” but it does not justify secrets that should not be on the client in the first place.
flowchart TB
accTitle: How a shared long-lived secret ripples out
accDescr: A diagram showing that when a long-lived secret is handed identically to every client, the secret can be extracted from any single machine where the app can decrypt it, so a breach of one machine ripples out to everything.
com1["A long-lived secret shared by all clients"] --> one4["The app can decrypt it on some machine"]
one4 --> ext1["The secret can be extracted from that one machine"]
ext1 --> all1["It ripples out to everything"]
com1 -.-> np2["Storing it with DPAPI is not a fundamental fix"]
Figure 8: A shared secret means one fallen machine ripples out to everything, so revisit where it lives rather than how it is stored.
Secrets of this kind are better steered in the following directions than stored more cleverly:
- keep them on the server side
- have the client hold only tokens
- make them per-user credentials
- make them expiring tokens
6. Choosing Between CurrentUser and LocalMachine
This part matters a lot. Choose carelessly and the meaning changes.
6.1. The Default Is CurrentUser
For an ordinary Windows desktop app, start from CurrentUser.
Good fits:
- User-facing WPF / WinForms / WinUI desktop apps
- Apps with per-user settings and credentials
- Apps that keep settings under
%LocalAppData%or%AppData%
In this case, it becomes natural to treat the data as “that Windows user’s secret.”
6.2. LocalMachine Has Quite Limited Use Cases
LocalMachine looks convenient, but for an ordinary desktop app it is too broad.
It is suited to cases like:
- A Windows service on a trusted, single-purpose machine
- Secrets used only by specific processes on that machine
- Cases that genuinely must work across logon users on the same machine
But the caveats are heavy.
- Broadly decryptable by processes running on that PC
- Tends to become dangerous on shared machines, RDS, jump hosts, and multi-user environments
- Choosing it because “everyone can use it, so it is easy” usually causes trouble later
And the motives for reaching for LocalMachine are usually these three:
- it can still be read after switching users
- services can read it too
- it is convenient if it just works
Every one of them is about “easy,” not about “protected.”
Choosing LocalMachine in an ordinary desktop app extends decryptability to other processes on that PC, which changes the meaning considerably.
flowchart TB
accTitle: What happens when LocalMachine is chosen for convenience
accDescr: A diagram showing that choosing LocalMachine for the convenience of reading across users and from services extends decryptability to other processes on that PC, which is a different state from being protected.
ease1["Easy across users and from services"] --> pick4["Choose LocalMachine"]
pick4 --> wide1["Decryptability extends to other processes on the PC"]
wide1 -.-> not1["That is easy, not protected"]
Figure 9: Choosing LocalMachine for convenience widens the decryptable range to the whole PC.
6.3. When in Doubt, Think Like This
- Ordinary UI app ->
CurrentUser - A special case that truly must be protected per machine ->
LocalMachine - Any user must be able to decrypt, but other users are on the machine -> usually better to revisit the design itself
6.4. Services and Impersonation Add Caution
Bring Windows services or impersonation into the mix, and the meaning of CurrentUser gets a bit heavier.
- Who is the execution account?
- Is that account’s profile loaded?
- In which context does decryption happen?
If these get misaligned, you easily end up with “it encrypted fine but will not decrypt.”
For service scenarios, “just use CurrentUser” is sometimes not enough.
With impersonation, the typical failure, spelled out in Microsoft Learn as well, is “Key not valid for use in specified state.” DPAPI keeps its key data in the user profile, so decryption fails when the profile is not loaded. You have to load the target user’s profile before impersonating.
flowchart TB
accTitle: The typical failure with impersonation
accDescr: A diagram showing that because DPAPI keeps its key data in the user profile, impersonating and decrypting while the profile is not loaded produces the typical error, and the target user profile has to be loaded first.
imp1["Impersonate without the profile loaded"] --> ferr1["Decryption fails with an error"]
ld1["Load the profile first"] --> okp1["Decryption works after impersonating"]
imp1 -.-> whyp1["The key lives in the profile"]
Figure 10: The key sits on the profile side, so the profile has to be loaded before impersonating.
6.5. Know the Cases Where Decryption Stops Working
In practice, losing the ability to decrypt hurts more than the encryption itself. DPAPI binds the party that can decrypt to a Windows user or a computer, so once that binding is broken, the data becomes unreadable.
These are the five cases worth knowing up front:
| Case | What happens | How to prepare |
|---|---|---|
| An administrator resets the password | The protection tied to the user’s password comes off, and DPAPI-protected data can become inaccessible. Microsoft support documents this as data protected by DPAPI becoming inaccessible after an administrator resets the password | Design so the secret can be re-acquired. Route a decryption failure into re-entry |
| The profile is recreated | A new profile carries different key material, so the earlier ciphertext cannot be decrypted | Version the configuration file, and do not turn a decryption failure into an abnormal termination |
| Only the ciphertext is copied to another PC | The key material decryption needs lives on the user profile side, so carrying only a CurrentUser ciphertext gets you nothing (this is the flip side of the “strength” listed in 3.1.) |
Design on the assumption that the secret is stored again per machine |
| Roaming profile | This one can be read. The key material moves with the profile, and Microsoft Learn states explicitly that a user with a roaming profile can decrypt from a different computer on the network. Treat it the same as the previous row and your migration procedure will recreate credentials for no reason | Do not assume “another PC means it cannot be read.” Confirm whether roaming is in play before deciding the migration procedure |
| The service execution account changes | If the execution account differs between protection time and decryption time, it cannot be read with CurrentUser |
Build a re-protection step into operations for account changes |
In short, write the code on the premise that ProtectedData.Unprotect can fail. A failed decryption throws CryptographicException, so catch it there and steer toward re-entry.
using System;
using System.Security.Cryptography;
using System.Text;
// protectedBase64: the ciphertext read from the configuration file (Base64)
// entropy: pass the same value used at protection time. null is acceptable
static bool TryUnprotect(string protectedBase64, byte[]? entropy, out string plaintext)
{
plaintext = string.Empty;
try
{
byte[] plainBytes = ProtectedData.Unprotect(
Convert.FromBase64String(protectedBase64),
optionalEntropy: entropy,
scope: DataProtectionScope.CurrentUser);
plaintext = Encoding.UTF8.GetString(plainBytes);
return true;
}
catch (CryptographicException)
{
// Cannot decrypt = the environment most likely changed.
// Do not crash here; let the caller route into the re-entry flow
return false;
}
catch (FormatException)
{
// When the value is not valid Base64
return false;
}
}
When a support request arrives saying “we encrypted it but cannot decrypt it,” it is usually one of the rows in that table.
flowchart TB
accTitle: A code flow that assumes decryption can fail
accDescr: A diagram showing the flow of writing code on the premise that ProtectedData.Unprotect can fail when the environment changes, catching CryptographicException instead of terminating abnormally, and steering into the re-entry flow.
upx1["Try Unprotect"] -->|"Success"| use2["Use the secret"]
upx1 -->|"Fails with an exception"| ctc1["Catch the exception and do not crash"]
ctc1 --> rein1["Steer into the re-entry flow"]
upx1 -.-> why2["It can fail once the binding is broken"]
Figure 11: Write on the premise that decryption can fail, and connect a failure to re-entry rather than to an abnormal termination.
7. Minimum Implementation Guidelines
If all you want is to “stop having plaintext in the configuration file” in a Windows app, the design does not need to be very complex. But there are a few points you do not want to miss.
7.1. Protect Only the Secrets
Rather than encrypting the entire configuration wholesale, it is easier to handle if you protect only the secret items first.
For example, split it like this.
- Server URL
- Username
- Database name
- Feature flags
These can often stay in plaintext.
Whereas:
- Passwords
- API tokens
- Refresh tokens
- Shared folder credentials
are the protection targets.
With this split, you get:
- easier configuration editing
- easier diff review
- clarity about what is secret
- simpler overall operations
flowchart TB
accTitle: Splitting so that only the secret items are protected
accDescr: A diagram showing that instead of encrypting the whole configuration wholesale, leaving URLs and usernames in plaintext and protecting only secret items such as passwords and tokens keeps the file editable and makes clear what is secret.
cfg2["Configuration file"] -->|"Stays in plaintext"| pl1["URL, username, flags, and so on"]
cfg2 -->|"Protected"| sc1["Passwords, tokens, and so on"]
sc1 --> mr1["What is secret is clear and operations stay simple"]
Figure 12: Protecting only the secret items rather than encrypting everything keeps both ease of handling and safety.
7.2. Default the Storage Location to Per-User
For an ordinary desktop app, default the storage location to a per-user place.
%LocalAppData%\Vendor\App\settings.json%AppData%\Vendor\App\settings.json
At minimum, do not casually put it under the installation folder or somewhere easily shared.
Even with DPAPI protection, if the ACLs on the storage location are sloppy, you end up with “the ciphertext gets read,” “the configuration structure is visible,” “operational mistakes happen.” Defense works better layered, not single-tiered.
7.3. optionalEntropy Is Not an Almighty Second Key
ProtectedData lets you pass optionalEntropy.
This is handy, but it is not “a magic second key that makes you safe if you embed it in the binary.”
- Put it in the same file and it is not a secret
- Embed it as a fixed value in the binary and it is not a strong secret either
- Even so, it is useful for purpose identification and misuse prevention
In practice, passing a fixed byte sequence built from:
- the application name
- the purpose name
- a version identifier
and using it “to avoid accidentally accepting ciphertext from a different purpose” is about the right level.
flowchart TB
accTitle: Where optionalEntropy actually belongs
accDescr: A diagram showing that optionalEntropy is not a magic second key that becomes safe once embedded in the binary, but is best used as purpose identification, passing the application name and purpose name as a fixed byte sequence so ciphertext from a different purpose is not accepted by mistake.
ent1["optionalEntropy"] -.->|"Do not expect this"| key2["A magic second key"]
ent1 -->|"This use is about right"| tag1["An identifier for the app name and purpose"]
tag1 --> guard1["Ciphertext from a different purpose is not accepted by mistake"]
Figure 13: Entropy is not a secret key but a tag for purpose identification and misuse prevention.
7.4. This Does Not Mean Ciphertext May Go into Git
This is quietly important too.
DPAPI ciphertext is far better than plaintext, but that does not mean the configuration file may go into the repository.
The reasons are simple:
- Ciphertext lives a long time
- The same machine or the same context may someday be reproduced
- The file contains information besides the secret
- A culture of “it is protected, so we can be sloppy with it” takes root
“Better than plaintext” and “safe anywhere you put it” are entirely different things.
flowchart TB
accTitle: Why even ciphertext stays out of Git
accDescr: A diagram showing that DPAPI ciphertext is better than plaintext but still stays out of the repository, because it lives a long time, the file carries information besides the secret, and a culture of being sloppy because it is protected takes root, which is a separate matter from being safe anywhere.
enc1["DPAPI ciphertext"] -->|"Better than plaintext"| bet2["Holds up better when things go wrong"]
enc1 -.->|"Even so"| git1["Keep it out of the repository"]
git1 --> rs1["It lives long, carries other information, and loosens the culture"]
Figure 14: Better than plaintext is not a reason to be careless about where it lives.
7.5. Do Not Put It in Logs
A surprisingly common pattern is ruining everything by logging the value after decryption.
- Dumping the entire connection string on connection failure
- Leaving the Authorization header in logs on an API 401
- Mixing secrets into exception messages
Do these, and even after eliminating plaintext configuration files, your logs become the plaintext warehouse instead. Sad, but very much real-world.
8. A Minimal C# / .NET Implementation
8.1. First, You Need to Add a Reference
ProtectedData looks like it is part of the BCL, but where it comes from depends on the target framework. Trip over this and the type name ProtectedData will not resolve.
| Target | What you have to do | Provided by |
|---|---|---|
| .NET Framework | Add a System.Security assembly reference to the project |
System.Security.dll |
| .NET Core / .NET 5 and later (including .NET 6 / 8) | Add the NuGet package System.Security.Cryptography.ProtectedData |
System.Security.Cryptography.ProtectedData.dll |
This package is not included in any shared framework of .NET Core / .NET 5 and later. Even on a Windows-targeting TFM such as net8.0-windows, the reference has to be explicit.
dotnet add package System.Security.Cryptography.ProtectedData
There is one more thing worth knowing before you start implementing.
ProtectedData is Windows-only. It depends on DPAPI, so calling it on .NET on a non-Windows platform throws PlatformNotSupportedException. If your codebase assumes cross-platform, design differently from the start, as 10.1. describes.
flowchart TB
accTitle: What to confirm before using ProtectedData
accDescr: A diagram showing the two things to confirm before implementing - a reference matching the target has to be added or the type name will not resolve, and ProtectedData is Windows-only so calling it elsewhere raises an exception.
use3["Want to use ProtectedData"] --> ref1["Add the reference that matches the target"]
ref1 -.->|"Without it"| unres1["The type name does not resolve"]
use3 --> winonly1["Understand that it is Windows-only"]
winonly1 -.->|"Called outside Windows"| pnse1["Throws at run time"]
Figure 15: Before implementing, nail down the two prerequisites: adding the reference, and Windows-only.
8.2. The Minimal Implementation
Below is a minimal example protecting a string saved to a configuration file with CurrentUser.
It includes a fixed optionalEntropy for purpose identification, but do not think of this as a secret key.
using System;
using System.Security.Cryptography;
using System.Text;
public static class DpapiSecretProtector
{
// For purpose identification. Not a second secret key.
private static readonly byte[] Entropy =
Encoding.UTF8.GetBytes("ComComponent:DesktopApp:SettingsSecret:v1");
public static string ProtectToBase64(string plaintext)
{
ArgumentNullException.ThrowIfNull(plaintext);
byte[] plainBytes = Encoding.UTF8.GetBytes(plaintext);
byte[] protectedBytes = Array.Empty<byte>();
try
{
protectedBytes = ProtectedData.Protect(
plainBytes,
optionalEntropy: Entropy,
scope: DataProtectionScope.CurrentUser);
return Convert.ToBase64String(protectedBytes);
}
finally
{
Array.Clear(plainBytes, 0, plainBytes.Length);
if (protectedBytes.Length > 0)
{
Array.Clear(protectedBytes, 0, protectedBytes.Length);
}
}
}
public static string UnprotectFromBase64(string protectedBase64)
{
ArgumentNullException.ThrowIfNull(protectedBase64);
byte[] protectedBytes = Convert.FromBase64String(protectedBase64);
byte[] plainBytes = Array.Empty<byte>();
try
{
plainBytes = ProtectedData.Unprotect(
protectedBytes,
optionalEntropy: Entropy,
scope: DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(plainBytes);
}
finally
{
Array.Clear(protectedBytes, 0, protectedBytes.Length);
if (plainBytes.Length > 0)
{
Array.Clear(plainBytes, 0, plainBytes.Length);
}
}
}
}
Usage is simple.
string protectedPassword = DpapiSecretProtector.ProtectToBase64(password);
// Save to JSON etc.
// settings.DbPasswordProtected = protectedPassword;
string password = DpapiSecretProtector.UnprotectFromBase64(settings.DbPasswordProtected);
The configuration file can then take a form like this.
{
"ApiBaseUrl": "https://api.example.com/",
"UserName": "app-user",
"PasswordProtected": "AQAAANCMnd8BFdERjHoAwE..."
}
The nice things about this form:
- The URL and username can be edited normally
- Only the password is protected
- The configuration structure is easy to read
- Fewer things go wrong than leaving it in plaintext
9. Designs That Are Still Dangerous
Even with DPAPI in use, the following designs are still dangerous.
9.1. Carrying the Decrypted Value Around for a Long Time
You want to avoid taking the decrypted value and:
- putting it in logs
- putting it on screen
- including it in exceptions
- leaving it parked on long-lived objects
“Encrypted at rest” and “safe while in use” are separate problems.
9.2. Giving Every Installation a Common Secret
A design where every user holds the same API key is not fundamentally fixed by storing it with DPAPI. The reasons, and where to steer such secrets instead, are collected in 5.2.
9.3. Choosing LocalMachine Because “It Is Easy”
This one really is common. But “easy” is not “protected.” The motives for reaching for it, and what widens when you do, are covered in 6.2. If the call is hard to make, look at the three lines in 6.3.
9.4. Adding Roll-Your-Own Crypto and Feeling Safe
Instead of DPAPI, implementations like:
- embedding an AES key in the source code
- putting the AES key in another field of the configuration file
- treating a “slightly obfuscated string” as a key
are usually of little effect.
Between “not plaintext” and “secure” lies a rather large gulf.
flowchart TB
accTitle: The danger of feeling safe with roll-your-own crypto
accDescr: A diagram showing that roll-your-own crypto that puts an AES key in the source code or in another configuration field, or treats an obfuscated string as a key, has little effect, and that a large gulf separates not being plaintext from being secure.
hm1["Roll-your-own crypto with the key in code or config"] --> npl1["A state of not being plaintext"]
npl1 -.->|"A large gulf between them"| sfe1["A state of being secure"]
hm1 -.-> thin1["The effect is usually slight"]
Figure 16: Roll-your-own crypto with the key in the same place is only “not plaintext” and never reaches “secure.”
10. Cases Where DPAPI Is Not Enough
DPAPI is useful, but not almighty. Consider other options in the following cases.
10.1. You Want to Run on Platforms Other Than Windows
DPAPI / ProtectedData are for Windows.
A cross-platform app cannot be built on that assumption.
10.2. You Want the Same Secret Across Multiple Machines and Users
Requirements like decrypting the same ciphertext on multiple PCs, or sharing it across multiple users, fall outside the strength of DPAPI, which binds to “that machine, that user.”
In that case, consider designs that fit the requirement:
- server-side secret management
- a credential infrastructure
- Windows authentication / integrated authentication
- a credential store for the application
10.3. The Thing Being Stored Is a User Credential Itself
If what you want to store is clearly a pair of:
- username
- password
then a credential store Windows already provides is more straightforward than writing it to your own file with DPAPI. This is a frequent point of hesitation in practice, so here is a comparison.
| Aspect | DPAPI (ProtectedData) |
Credential Locker (PasswordVault) |
Credential Manager (CredWrite / CredRead) |
|---|---|---|---|
| Where it is stored | A file you choose yourself (how the ciphertext is placed is up to the app) | A credential store Windows manages | A credential store Windows manages |
| What can be stored | Any byte sequence (a connection string, a token, or part of the configuration) | A username + password pair | Credentials (a struct per credential type) |
| API | System.Security.Cryptography |
WinRT Windows.Security.Credentials |
Win32 (wincred.h / Advapi32.dll) |
| Usable from a desktop app | Usable as is | Usable from WPF / WinForms, not just WinUI (the setup for calling WinRT APIs is required) | Usable as is |
| Sync | None | Roams across machines with a Microsoft account | None (a local set of user credentials) |
| Limits | Effectively none | Up to 20 entries per app. Not meant for large data | Tied to the logon session of the current token |
| Who manages it | The app (you decide the storage location and the ACLs) | The OS (no storage location to design) | The OS (manageable from Credential Manager in Control Panel) |
Rough guidance on choosing:
- What you store is a username + password pair, and there are few of them -> Credential Locker / Credential Manager is the first candidate. You do not have to own the storage design or the ACLs
- What you store is not shaped like “username + password” -> connection strings, API tokens, refresh tokens, or parts of a configuration file are more straightforward on the DPAPI side. That is what this article deals with
- You want it to carry across machines -> Credential Locker roaming works.
CurrentUserin DPAPI has the opposite goal, since “it does not carry over” is precisely its advantage - There are many entries, or they are large -> you hit the 20-entry limit of Credential Locker. Move to your own file with DPAPI
Note that the credential stores also ride on the OS protection machinery internally, so this is not a ranking of “safer than DPAPI” or “DPAPI is inferior.” The practical approach is to choose based on the shape of what you store and whether you need roaming.
And whichever you choose, for a new app it is worth first considering passwordless options such as Windows Hello or passkeys. If you can avoid holding a long-lived password at all, that is the strongest position.
The center of this article remains the practical DPAPI baseline for “stopping plaintext configuration files on Windows clients.”
11. Recommended Priorities in Practice
Finally, when in doubt in practice, thinking in this order keeps things organized. Work down from the top, and drop to the next level only when the conditions are not met.
flowchart TD
Q1{"Can we avoid keeping a<br/>long-lived secret on the machine"}
Q1 -->|"Yes"| A1["Priority 1: do not hold it<br/>(Windows auth, short-lived tokens)"]
Q1 -->|"No"| Q2{"Can the secret be split<br/>per user"}
Q2 -->|"No"| A2["Check whether it has become<br/>a shared key and revisit the design"]
Q2 -->|"Yes"| QF{"Is what you store a pair of<br/>username + password (Section 10.3)"}
QF -->|"That pair, and few of them"| QR1{"Does it need to carry across machines"}
QR1 -->|"Yes"| QA{"Machines synced with a<br/>Microsoft account (Section 10.3)"}
QA -->|"Yes"| CL2["Use Credential Locker<br/>roaming"]
QA -->|"Domain / local account"| SRV
QR1 -->|"No"| CL["Credential Locker /<br/>Credential Manager"]
QF -->|"A token or another shape"| QR2{"Does it need to carry across machines"}
QR2 -->|"Yes"| SRV["DPAPI cannot carry it over.<br/>Manage it server-side (Section 10.2)"]
QR2 -->|"No"| Q3{"How many accounts decrypt<br/>the same secret"}
Q3 -->|"One is enough (the user, or a<br/>dedicated service account)"| A3["Priority 3: DPAPI + CurrentUser<br/>For unattended runs, confirm that<br/>the profile is loaded (Section 6.4)"]
Q3 -->|"Several accounts must be<br/>able to decrypt"| Q4{"Can you say for certain that<br/>no other users log on"}
Q4 -->|"Yes, for certain"| A4["Priority 4: DPAPI + LocalMachine<br/>Treat it as an exception and record the rationale"]
Q4 -->|"Not for certain"| A5["Other users could decrypt it too.<br/>Revisit the authentication method instead"]
Figure 17: The order for selecting a storage method. Look at the shape of the secret and whether roaming is needed before you get to DPAPI. Choose LocalMachine not because it is easy, but as an exception when no other option holds up
Priority 1: Do not hold it at all
- Windows authentication
- Integrated authentication
- Interactive login
- Server-side secret keeping
- Short-lived tokens
Priority 2: Move toward per-user secrets
- Per-user over shared secrets
- Renewable tokens over long-lived fixed credentials
- Avoid keys common to all clients
Priority 3: If local storage is needed, DPAPI
- Normally
CurrentUser - Per-user storage location
- Protect only the secret items
- Keep it out of logs
Priority 4: Treat LocalMachine as the exception
- Does it truly need to be per machine?
- Do other users ever log on to that machine?
- Is it sound as a service design?
12. Summary
When a Windows app must store sensitive information in a configuration file, you want to avoid leaving it in plaintext.
And to the question:
“The key gets stored somewhere anyway, so isn’t it the same?”
the practical answer is this:
- Roll-your-own crypto with the key in the same place is, mostly, the same
- DPAPI is not the same
- Key management can be delegated to the OS
- The decrypting party can be bound to a Windows user / computer
- A leak of the file by itself no longer has to equal a leak of the secret
- However:
- code running with the same user privileges
- a fully compromised machine
- long-lived shared secrets that should not be on the client
are not solved by it
In short, DPAPI is not an almighty fortress wall. But it does have roughly the effect of replacing the wide-open window pane that is a plaintext configuration file with at least a decent window.
In real-world Windows client work, that difference is substantial. Starting by getting this part right is the most realistic move.
flowchart TB
accTitle: Where DPAPI realistically sits
accDescr: A diagram showing that DPAPI is not an almighty fortress wall but does replace the wide-open window pane of plaintext configuration with at least a decent window, and that starting there is the realistic move.
glass1["Plaintext configuration = a wide-open window pane"] -->|"Replace with DPAPI"| win2["At least a decent window"]
win2 -.-> notwall1["Not an almighty fortress wall"]
win2 --> first1["Starting here is the realistic move"]
Figure 18: DPAPI is a window replacement rather than a fortress wall, but in practice that difference is what counts most.
13. References
- Previous article: /en/blog/2026/03/14/001-windows-app-security-minimum-checklist/
- Microsoft Learn:
CryptProtectDatahttps://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata - Microsoft Learn:
ProtectedDatahttps://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.protecteddata?view=windowsdesktop-10.0 - Microsoft Learn:
DataProtectionScopehttps://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.dataprotectionscope?view=windowsdesktop-10.0 - Microsoft Learn: How to: Use Data Protection https://learn.microsoft.com/en-us/dotnet/standard/security/how-to-use-data-protection
- Microsoft Learn: Credential locker for Windows apps https://learn.microsoft.com/en-us/windows/apps/develop/security/credential-locker
- Microsoft Learn:
CredWrite(the Win32 API of Windows Credential Manager) https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credwritew - NuGet: System.Security.Cryptography.ProtectedData https://www.nuget.org/packages/System.Security.Cryptography.ProtectedData
- Microsoft Support: You cannot access DPAPI data after an administrator resets your password https://support.microsoft.com/en-us/topic/you-cannot-access-dpapi-data-after-an-administrator-resets-your-password-on-a-windows-server-2012-based-domain-controller-4aa890cd-12b5-fe5c-9e68-06244e70673d
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
How to Concretely Isolate "Only the Operations That Need Administrator Privileges" in a Windows App
A concrete walkthrough of keeping a Windows app UI at asInvoker while isolating only the administrator-privileged operations into a helpe...
A Minimum Security Checklist for Windows App Development
A checklist-style guide to the security basics for WPF / WinForms / WinUI / C++ / C# business apps: privileges, signing, updates, secrets...
Why Windows Became What It Is Today: The Evolution of Windows Through a Developer's Eyes
A look at the changes from Windows 95 to Windows 11 — not as a visual timeline, but from a Windows application developer's perspective: c...
Named Pipes in Practice — Windows' Standard IPC from Design to Security
A practical guide to named pipes, Windows' standard IPC. Covers byte vs. message mode, servers that handle multiple clients, ACL and impe...
The Depths of Windows I/O (Part 6, Final) — How Minifilters Work and Investigating Slow I/O with Procmon
Explains how minifilters monitor and control file I/O: FltMgr, altitudes, pre/post callbacks, fltmc, finding slow operations in Procmon, ...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
This topic touches the overall design of a Windows app — how credentials are stored, where per-user settings live, and what goes into logs — so it fits well with our Windows application development service.
Technical Consulting & Design Review
If you want to start by reviewing plaintext configuration in an existing app or sorting out when to use DPAPI versus the Credential Locker, this topic works well as a technical consulting / design review engagement.
Frequently Asked Questions
Common questions about the topic of this article.
- What is DPAPI?
- DPAPI (Data Protection API) is the data protection mechanism Windows provides. It delegates encryption key management to the OS and binds the party that can decrypt to that Windows user or that computer. From C# / .NET you reach it through the System.Security.Cryptography.ProtectedData class without adding any extra library. It is closer to the truth to see it as an API that delegates key management to the OS than as an API for choosing an encryption algorithm. It is the realistic option for not leaving passwords and API tokens sitting in a configuration file in plaintext.
- The key has to be stored somewhere anyway, so isn't plaintext the same as DPAPI?
- They are not the same. Roll-your-own AES with the key placed in the same app or the same configuration file is indeed close to plaintext, but DPAPI moves key management onto the OS and binds the party that can decrypt to a Windows user or a computer. As a result, it holds up far better against mishaps such as a leak of the configuration file by itself, the file being carried to another PC, a mis-sent attachment, a backup leak, or repository contamination. The decisive difference from plaintext is that DPAPI can separate 'can read the file' from 'can use the secret'.
- What does DPAPI not protect?
- Code that runs with the same user privileges can, fundamentally, decrypt whatever that user can decrypt. So it does not protect a machine already compromised by malware, a machine taken over at the administrator level, or the plaintext in memory after decryption. A long-lived secret handed identically to every client is not fundamentally solved by storing it with DPAPI either, because extraction from any single machine tends to ripple out to everything. Where DPAPI helps is mainly the file-leak, misplacement, offline exfiltration, and other-user-access side.
- Should I use CurrentUser or LocalMachine for DataProtectionScope?
- For an ordinary Windows desktop app, CurrentUser is the default. The data can be treated as that user's secret, and you get the property that copying just the ciphertext to another PC does not make it readily usable. LocalMachine is broadly decryptable by processes running on that PC, so it easily becomes dangerous on shared machines and in environments where multiple users log on; its use cases are quite limited, mainly Windows services on trusted single-purpose machines. Choose LocalMachine because 'everyone can use it and that is easy' and you will usually regret it later.