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.21614495)
- First published
Cite this article(DOI: 10.5281/zenodo.21614494)
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). A Minimum Security Checklist for Windows App Development. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614494 https://comcomponent.com/en/blog/2026/03/14/001-windows-app-security-minimum-checklist/
- DOI (latest version)
- 10.5281/zenodo.21614494
- DOI (this version)
- 10.5281/zenodo.22217143
Download the Excel version of the checklist
The contents of that file are the same as the pre-release checklist in section 4 (8 categories, 32 items). The only differences are that it adds Status and Notes columns to fill in, and that it carries both languages in two sheets, Checklist-ja and Checklist-en. Use section 4 when you want to read and check as you go, and the Excel version when you want to hand out a review record.
Talk about Windows application security tends to balloon quickly. Zero trust, EDR, SBOM (Software Bill of Materials), certificate operations, vulnerability management. All important - but in practice there are several basics you do not want to miss first.
Especially for apps like the following, plugging gaps in the basics pays off more than advanced defenses:
- WPF / WinForms / WinUI desktop apps
- C++ / C# Win32 apps
- Device integration, file integration, DB connections, internally distributed tools
- Business apps with an auto-update mechanism
- Setups that include Windows services or helper EXEs
In Windows app development, it is more realistic to first leave no obviously dangerous holes than to try to perfect everything at once. Here we organize the minimum points you do not want to miss, in checklist-friendly form, in the order of design, implementation, distribution, and operations.
flowchart TB
accTitle: How this article proceeds
accDescr: Diagram showing the flow of the article, which plugs the gaps in the basics before advanced defenses and then works through the minimum points in the order of design, implementation, distribution, and operations.
adv1["Advanced defenses such as zero trust"] -.->|"before that"| base1["Plug the gaps in the basics"]
base1 --> o1["Design"]
o1 --> o2["Implementation"]
o2 --> o3["Distribution"]
o3 --> o4["Operations"]
Figure 1: Plug the gaps in the basics before advanced defenses, then work through design, implementation, distribution, and operations.
1. The Conclusion First
- The first things you do not want to miss are: do not request administrator privileges you do not need, sign your binaries, do not keep secrets in plaintext, and do not disable certificate validation.
- For a Windows app, the distribution artifacts themselves are an attack surface. It is safer to look at everything: EXE / DLL / MSI / MSIX / auto-update modules.
ServerCertificateValidationCallback => true, plaintext connection strings, careless loads likeLoadLibrary("foo.dll"), and SQL built by string concatenation are items to avoid even at the minimum bar.- If only part of your processing needs administrator rights, it is safer to split just that part into a separate EXE or service rather than elevating the whole app.
- Apps distributed on Windows should assume signing + timestamping. Beyond user trust, it also makes tamper detection and operational explanations easier.
- For secrets at rest, choose between DPAPI / ProtectedData and the Credential Locker depending on the use case. At a minimum, you want to escape the state of plaintext secrets in
appsettings.json. - More logging is not automatically better. Persist tokens, passwords, connection strings, personal information, or full request bodies as is, and the log itself becomes the breach.
Minimum security is less about adding special features and more about leaving no dangerous defaults or sloppy implementations in place.
flowchart TB
accTitle: What minimum security means
accDescr: Diagram showing that minimum security is not about adding special features but about leaving no dangerous defaults or sloppy implementations in place.
add1["Add special features"] -.->|"not the point of the minimum bar"| goal1["Minimum security"]
rm1["Leave no dangerous defaults or sloppy implementations"] -->|"this is the point"| goal1
Figure 2: The minimum bar is not about adding features - it is about leaving no dangerous defaults or sloppy implementations in place.
Knowledge map for this article
This article turns the minimum security measures that should not be dropped before release into a checklist for WPF/WinForms/WinUI/C++/C# line-of-business Windows apps. At its center are not marking the whole app as requireAdministrator but separating only the operations that need it into a separate process or service, attaching a code signature and a timestamp to what is distributed so that unsigned distribution and permanently skipping certificate validation are avoided, and protecting secrets with DPAPI or something similar instead of leaving them in plaintext settings. It also covers preventing the SQL injection that string concatenation in SQL invites by using parameter placeholders, the fact that loading a DLL by name alone invites search order hijacking, and the fact that writing confidential data to logs turns the logs themselves into a leak path.
flowchart LR
accTitle: Minimum security checklist for Windows apps
accDescr: Diagram showing how the minimum security items relate to each other: handling administrator privileges, code signing and verification of what is distributed as an update, protecting secrets, input and runtime risks such as SQL injection and DLL loading, and leaking confidential data through logs
admin_rights["Administrator Privileges"]
code_signing_cert["Code signing certificate"]
app_secrets["Application Secrets"]
execution_level_asinvoker["asInvoker Execution Level"]
execution_level_requireadministrator["requireAdministrator Execution Level"]
desktop_app["Interactive-user desktop app"]
admin_privilege_separation["Admin Privilege Separation"]
dll_search_order_hijacking["DLL Search Order Hijacking"]
unvalidated_dll_loading["Name-Only DLL Loading"]
unsigned_binary["Unsigned Binary"]
code_signing_timestamp["Code signing timestamp"]
certificate_expiry["Certificate Expiration"]
update_integrity_verification["Update Signature and Hash Verification"]
unverified_update_application["Applying Unverified Updates"]
certificate_pinning["Certificate Pinning"]
certificate_validation_bypass["Unconditional Certificate Validation Bypass"]
certificate_revocation_check["Certificate Revocation Check"]
sql_string_concatenation["SQL String Concatenation"]
sql_injection["SQL Injection"]
prepared_statement["Prepared Statements (Placeholders)"]
secret_logging["Writing Secrets to Logs"]
secret_log_exposure["Secret Exposure Through Logs"]
dpapi["DPAPI"]
plaintext_secret_storage["Plaintext Secret Storage"]
windows_service["Windows Service"]
admin_rights -->|"configured by"| execution_level_asinvoker
admin_rights -->|"configured by"| execution_level_requireadministrator
execution_level_requireadministrator -->|"not recommended for"| desktop_app
admin_privilege_separation -->|"recommended for"| execution_level_requireadministrator
execution_level_requireadministrator -.->|"may cause"| dll_search_order_hijacking
unvalidated_dll_loading -->|"may cause"| dll_search_order_hijacking
code_signing_cert -->|"prevents"| unsigned_binary
code_signing_timestamp -->|"mitigates"| certificate_expiry
update_integrity_verification -->|"prevents"| unverified_update_application
code_signing_cert -->|"recommended for"| update_integrity_verification
certificate_pinning -->|"recommended for"| certificate_validation_bypass
certificate_pinning -.->|"requires"| certificate_revocation_check
certificate_validation_bypass -->|"not recommended for"| desktop_app
sql_string_concatenation -->|"may cause"| sql_injection
prepared_statement -->|"prevents"| sql_injection
secret_logging -->|"may cause"| secret_log_exposure
dpapi -->|"recommended for"| plaintext_secret_storage
app_secrets -.->|"requires"| dpapi
admin_privilege_separation -.->|"uses"| windows_service
code_signing_cert -.->|"requires"| code_signing_timestamp
prepared_statement -->|"recommended for"| desktop_app
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. Scope of This Article, and What “Minimum” Means
2.1. What we cover
The Windows apps this article has in mind are these.
- WPF / WinForms / WinUI desktop apps
- C++ / C# Win32 apps
- Internally distributed tools, device-integration tools, monitoring tools
- Setups that include helper EXEs, Windows services, and updaters
- Business software distributed as EXE / MSI / MSIX
“Minimum” here does not mean a final state that passes an audit - it means items that, if missing, will simply cause incidents.
flowchart TB
accTitle: What minimum means in this article
accDescr: Diagram showing the distinction that the minimum in this article is not a final state that passes an audit but the items that will simply cause incidents when they are missing.
au1["Final state that passes an audit"] -.->|"not what we mean here"| mn1["The minimum in this article"]
ac1["Items that cause incidents when missing"] -->|"this is what we mean"| mn1
Figure 3: “Minimum” points not at the final state for an audit but at the items that cause incidents when missing.
Let us also line up the assumptions behind the code samples. The C# examples assume .NET 8 or later, and the C++ examples assume the Win32 API. The thinking carries over to .NET Framework 4.8, but the recommended way of writing some of it has changed. The prime example is the ServicePointManager material in 3.6: new code is expected to use IHttpClientFactory and HttpClient instead. Keep that in mind when you revisit older code.
2.2. What we don’t cover
On the other hand, some topics sit outside the center of this article.
- Company-wide zero trust design
- Overall operation of EDR / SIEM / DLP / MDM
- Detailed hardening of kernel drivers
- Designing cryptography itself from scratch
- Advanced threat analysis and forensic procedures
In other words, this is not about massive organization-wide security programs. It covers the baseline that a Windows app developer has trouble covering alone before release.
flowchart TB
accTitle: Where the scope line is drawn
accDescr: Diagram showing the scope line, which leaves massive organization-wide security programs out and covers the baseline that a Windows app developer has trouble covering alone before release.
org1["Massive organization-wide programs"] -.->|"not covered here"| sc1["Scope of this article"]
dev1["Baseline developers struggle to cover alone"] -->|"this is what we cover"| sc1
Figure 4: What we cover is not organization-wide programs but the baseline a developer can secure alone before release.
3. The Checklist to Look at First
Before the detailed discussion, here is a table that gives you the whole landscape. This alone is enough to spot where to start reviewing.
3.1. The big picture
| Item to check | Minimum action | Typical anti-pattern |
|---|---|---|
| Execution privileges | Default to asInvoker; isolate only the operations that need elevation |
Marking the whole app requireAdministrator |
| Trustworthiness of artifacts | Code-sign EXE / DLL / MSI / MSIX, with timestamps | Shipping unsigned |
| Updates | Pin the update source; detect tampering via HTTPS and signature checks | Downloading over HTTP and overwriting in place |
| Secrets | Keep secrets out of source code and plaintext config; use DPAPI / Credential Locker etc. | API keys and connection strings in plaintext config files |
| Communication | Use HTTPS; never disable certificate validation | Permanently skipping certificate validation with return true |
| External input | Validate everything: SQL, files, IPC, URIs, CSV, JSON | Waving it through because it is an internal tool |
| DLL loading | Use absolute paths, SetDefaultDllDirectories, and a safe search order |
Leaving LoadLibrary("foo.dll") to the current directory |
| Logging | Mask tokens, passwords, PII; separate user-facing errors from internal logs | Displaying or persisting exception details and connection strings as is |
| Dependencies | Continuously update SDKs, NuGet, VC++ runtimes, OSS dependencies | Freezing versions for years and ignoring vulnerability reports |
3.2. Default privileges to asInvoker
This is the first thing to review in a Windows app. Run the whole app with administrator rights, and bugs, DLL substitution, misread config files, and unvalidated external input all execute with those strong privileges.
flowchart TB
accTitle: The danger of elevating the whole app
accDescr: Diagram showing that when the whole app runs with administrator rights, bugs, DLL substitution, misread config files, and unvalidated external input all execute with those strong privileges.
all1["Run the whole app with administrator rights"] --> bug1["Bugs"]
all1 --> swp1["DLL substitution"]
all1 --> inp1["Misread config and unvalidated input"]
bug1 --> pw1["Executes with strong privileges as is"]
swp1 --> pw1
inp1 --> pw1
Figure 5: Elevate the whole app and every defect it carries executes with strong privileges.
The basic policy is this.
- Ordinary UI apps run as
asInvoker - Only the operations that need administrator rights get split into a separate process or service
- Elevate only for the moments that need it
- Validate the input passed to helper EXEs and services too
If your desktop app normally only views and edits, and only installation or firewall configuration changes need administrator rights, it is safer to push just the elevated parts into a broker rather than making the whole app requireAdministrator.
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
“It’s easier if it just runs as admin” almost always comes back to bite. Run with least privilege and carve out only the operations that truly need more, and the blast radius shrinks considerably.
flowchart TB
accTitle: Isolating only the operations that need elevation
accDescr: Diagram showing the arrangement where the ordinary UI app runs as asInvoker, only the operations that need administrator rights go to a broker in a separate process or service, and elevation happens only at the moments it is required.
ui1["UI app (asInvoker)"] -->|"requests only when needed"| br1["broker (separate EXE / service)"]
br1 --> el1["Runs only the operations that need elevation"]
ui1 -.-> vd1["Validate the input passed to the broker too"]
Figure 6: Run as asInvoker normally and push only the elevated work into a broker, and the blast radius shrinks.
3.3. Sign your binaries and installers
On Windows, the trustworthiness of your distribution artifacts carries real weight. What users touch is not your source code - it is the EXE, DLL, MSI, MSIX, and updater. Leave these unsigned and your operational story, tamper detection, and distribution-time confidence all weaken.
flowchart TB
accTitle: What users touch is the distribution artifact
accDescr: Diagram showing that users touch the EXE, DLL, installer, and updater rather than the source code, and that leaving those unsigned weakens both tamper detection and the operational explanation.
us1["What users touch"] --> bin1["EXE / DLL"]
us1 --> pkg1["MSI / MSIX"]
us1 --> upd1["Updater"]
bin1 --> ns1["Unsigned weakens tamper detection and the explanation"]
pkg1 --> ns1
upd1 --> ns1
Figure 7: The attack surface is the artifact itself, and leaving it unsigned removes the basis for trusting it.
At minimum, look at these.
- Sign EXE / DLL / MSI / MSIX
- Sign not just the installer but the helper binaries used for updates
- Add timestamps
- Include certificate expiry and renewal procedures in the release process
Signatures without timestamps in particular cause trouble during validation after the certificate expires. Rather than “it’s signed, so we’re done,” build signing + timestamping into the release procedure for stability.
flowchart TB
accTitle: Signing and timestamping
accDescr: Diagram showing that a signature without a timestamp causes trouble when validated after the certificate expires, and that building signing plus timestamping into the release procedure keeps things stable.
sg2["Signature only"] --> tr1["Validation gets awkward once the certificate expires"]
ts1["Signature + timestamp"] --> st2["Validation still holds up after expiry"]
ts1 -.-> fl1["Build it into the release procedure"]
Figure 8: Do not stop at signing - carry it through to timestamping and put both in the release procedure.
If you use MSIX, package signing is a given. Even with MSI / EXE distribution, at least the installer itself and the main executable binaries should be signed.
3.4. Pin the update channel and add tamper detection
For a modern Windows app, the update channel sees far more use over its lifetime than the initial install. Get this wrong, and however carefully you built the app body, the updater becomes the weakest point.
flowchart TB
accTitle: The update channel gets the most use
accDescr: Diagram showing that the update channel is used far longer than the initial install, so sloppy update handling makes the updater the weakest point in the whole app.
ins1["Initial install"] -.->|"used once"| ap1["Lifetime of the app"]
up2["Update channel"] -->|"used far longer"| ap1
up2 --> wk2["Sloppy handling makes it the weakest point"]
Figure 9: The update channel outlives the initial install, and building it carelessly makes it the biggest weakness.
The minimum five things to think through around updates:
- Fetch update files over HTTPS, always
- Verify the signature or hash of downloaded update artifacts
- Make sure the update source URL cannot be swapped arbitrarily via code or configuration
- Sign the update module itself
- Decide rollback and failure-recovery procedures
If you can adopt MSIX + App Installer, you can push much of the update machinery toward the OS. If you run your own updater instead, you must verify both transport security and artifact authenticity. HTTPS protects the channel, but it does not guarantee that this file is genuinely something you published.
flowchart TB
accTitle: Two things to verify during an update
accDescr: Diagram showing that a custom updater has to verify both transport security through HTTPS and authenticity through the signature or hash of the downloaded update artifact.
dl1["Fetch the update file over HTTPS"] --> vf1["Verify the signature or hash"]
vf1 --> ap2["Apply only what passes verification"]
dl1 -.-> lim1["HTTPS protects only the transport"]
vf1 -.-> own1["Confirms whether the file is really yours"]
Figure 10: Fetching over HTTPS says nothing about authenticity, so apply only after signature or hash verification passes.
3.5. Keep secrets out of source code and plaintext config
This is where real-world incidents truly happen. “It’s an internal tool,” “we’re just handing out an exe” - and connection strings, API keys, shared-folder credentials, and fixed tokens end up in source code or config files.
At minimum, avoid these arrangements.
- API keys hard-coded in source
- Plaintext passwords in
appsettings.jsonorapp.config - Connection strings checked into the repository
- Designs that keep the decryption key and the ciphertext in the same place
- Fixed credentials shared by everyone rather than per user
The realistic options for a Windows app come down to roughly these four.
- You want to store Windows credentials For packaged desktop apps / WinUI, consider the Credential Locker
- You want secrets encrypted at rest locally
For Win32 / .NET, use DPAPI /
ProtectedData - The target supports Windows authentication or integrated auth If possible, do not make the app hold a password at all
- Secrets can be managed on the cloud or server side Prefer designs that do not embed long-lived secrets in the client
In C#, even just using DPAPI as below is already far better than plaintext storage. Written out as a full save-and-load round trip, it looks like this.
// C# / .NET 8. ProtectedData is Windows-only, and on .NET it requires
// the NuGet package System.Security.Cryptography.ProtectedData.
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
public static class SecretStore
{
// The same value is required to decrypt. null works, but supplying one is safer.
private static readonly byte[] Entropy = [0x4b, 0x53, 0x2d, 0x76, 0x31];
public static void Save(string path, string secretText)
{
byte[] plaintext = Encoding.UTF8.GetBytes(secretText);
byte[] ciphertext = ProtectedData.Protect(
plaintext,
Entropy,
DataProtectionScope.CurrentUser);
// Writing the raw bytes to a file is fine, but use Base64 to put it in a config file.
File.WriteAllText(path, Convert.ToBase64String(ciphertext));
CryptographicOperations.ZeroMemory(plaintext);
}
public static string Load(string path)
{
byte[] ciphertext = Convert.FromBase64String(File.ReadAllText(path));
// Without the same user and the same entropy as at save time, this throws CryptographicException.
byte[] plaintext = ProtectedData.Unprotect(
ciphertext,
Entropy,
DataProtectionScope.CurrentUser);
try
{
return Encoding.UTF8.GetString(plaintext);
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
}
The calling side looks like this.
string path = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SampleApp",
"token.dat");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
SecretStore.Save(path, "example-api-key");
string restored = SecretStore.Load(path);
The important thing here is not “it’s encrypted, so it’s safe” but deciding in the design who can decrypt it.
Choosing CurrentUser or LocalMachine changes the meaning considerably. CurrentUser lets only the user who saved the value decrypt it; LocalMachine lets anyone on the same machine decrypt it. If you run as a service under a different account, or if your operations involve switching users, settle this up front or you will later get stuck on either “we cannot read it” or “anyone can read it.”
flowchart TB
accTitle: Who can decrypt with DPAPI
accDescr: Diagram showing that a CurrentUser DPAPI scope lets only the saving user decrypt while LocalMachine lets anyone on the same machine decrypt, so who can decrypt has to be decided in the design up front.
dc1["Decide in the design who can decrypt"] -->|"CurrentUser"| cu1["Only the saving user can decrypt"]
dc1 -->|"LocalMachine"| lm1["Anyone on the same machine can decrypt"]
dc1 -.-> lt1["Skip the decision and you get stuck later"]
Figure 11: The DPAPI scope decides who can decrypt, so settle who that is before you write the code.
Note that DPAPI keeps its key in the user profile, and the documentation states explicitly that decryption can fail when the profile is not loaded, for example during impersonation. If you plan to use it from a service, check this point as well.
flowchart TB
accTitle: The DPAPI key and the user profile
accDescr: Diagram showing that DPAPI keeps its key in the user profile, so decryption fails when the profile is not loaded, such as during impersonation.
ky1["The DPAPI key"] --> pf1["Lives in the user profile"]
pf1 -->|"profile not loaded, for example during impersonation"| fe1["Decryption fails"]
fe1 -.-> sv2["Check this before using it from a service"]
Figure 12: The DPAPI key lives in the user profile, so nothing decrypts while that profile is not loaded.
For SQL Server connections, in on-premises environments Windows authentication can sometimes be the first choice.
If you absolutely must include credentials in the connection string, at least keep Persist Security Info=False and do not leave them sitting in plaintext config files.
3.6. HTTPS by default - and never kill certificate validation
A bypass added just for development ships to production untouched. That is the pattern behind most communication-related incidents.
The code and settings that most often linger in shipped builds:
ServicePointManager.ServerCertificateValidationCallback += ... => trueHttpClientHandler.DangerousAcceptAnyServerCertificateValidator- Shipping with certificate revocation checks disabled
- Leaving code that assumes development self-signed certificates in production
The minimum policy is simple.
- Production traffic uses HTTPS
- Never skip certificate validation unconditionally
- If you genuinely need a validation exception, limit it to specific hosts and certificates
- Reliably strip development bypass code via build conditions or configuration
- In .NET, keep revocation checking in mind too
The bad example usually looks like this.
ServicePointManager.ServerCertificateValidationCallback +=
(_, _, _, _) => true;
It looks convenient, but it behaves close to “let this HTTPS connection through no matter who it connects to.” Strip certificate validation, and even with HTTPS the substance is largely hollowed out.
The right form depends on which .NET you target
Putting a global setting on ServicePointManager is a .NET Framework-era pattern. In new code it is more straightforward to take an HttpClient from IHttpClientFactory and, when TLS settings are needed, put them on SocketsHttpHandler or HttpClientHandler instead.
That said, assuming “it’s an old API, so it probably has no effect any more” is dangerous. Microsoft’s documentation states that ServicePointManager.ServerCertificateValidationCallback is mapped to RemoteCertificateValidationCallback in SocketsHttpHandler.SslOptions on .NET 9 and later. In other words, a single => true line somewhere can end up waving HttpClient traffic through as well.
flowchart TB
accTitle: How an old callback still reaches current traffic
accDescr: Diagram showing the path by which the ServicePointManager ServerCertificateValidationCallback is mapped to the SocketsHttpHandler validation callback on .NET 9 and later, so a single line returning true can wave HttpClient traffic through as well.
old1["The ServicePointManager validation callback"] -->|"mapped on .NET 9 and later"| new1["Validation on the SocketsHttpHandler side"]
new1 --> ef1["Applies to HttpClient traffic too"]
ef1 -.-> rk2["A single line returning true can hollow out all traffic"]
Figure 13: A validation bypass parked on an old API reaches today’s HttpClient traffic through that mapping.
When you do need to relax validation as an exception, keep it scoped to that one handler rather than to a global setting that affects the whole process.
// C# / .NET 8. Example of treating only one specific host and certificate as an exception.
// Even when relaxing things for development, an unscoped exception is no better than killing validation globally.
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
// Fingerprint of the target certificate. Reading it from configuration is fine too.
const string ExpectedThumbprint = "2B0C4E6A8D1F3B5D7F9A1C3E5A7C9E1B3D5F7A91";
var handler = new HttpClientHandler
{
CheckCertificateRevocationList = true, // Go check revocation. The default is false
ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) =>
{
if (errors == SslPolicyErrors.None)
{
return true;
}
// The only thing accepted here is that this machine does not trust the internal CA.
// A certificate that never arrived, or a host name mismatch, is not accepted
if (errors != SslPolicyErrors.RemoteCertificateChainErrors)
{
return false;
}
// Pin both the peer and the certificate
if (request.RequestUri?.Host != "device.internal.example"
|| certificate is null
|| !string.Equals(certificate.Thumbprint, ExpectedThumbprint,
StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Even for the one pinned certificate, expiry and revocation are not accepted.
// Accept them and you leave a path open for a certificate revoked after its key leaked
return chain is not null
&& chain.ChainStatus.All(s =>
s.Status is X509ChainStatusFlags.NoError
or X509ChainStatusFlags.UntrustedRoot
or X509ChainStatusFlags.PartialChain);
},
};
using var client = new HttpClient(handler);
Supply ExpectedThumbprint, the fingerprint of the target certificate, from a constant or from configuration.
What matters here is not turning a blind eye to errors and chain.ChainStatus. Return true just because the thumbprint matched, and that certificate keeps getting through after it expires, and after it has been revoked because its key leaked. Pinning means “trust only this one certificate,” not “trust this one certificate no matter what.” The code above allows only UntrustedRoot and PartialChain (that is, the internal CA is not installed on this machine); NotTimeValid (expired) and Revoked are still rejected.
Checking revocation requires CheckCertificateRevocationList = true (the default is false, and revocation is not checked). Conversely, if your internal CA publishes neither a CRL nor OCSP, you will be rejected with RevocationStatusUnknown. That is the correct behavior. If you cannot provide a way to check revocation, close the gap one of two ways: shorten the certificate validity period, or build a path for redistributing the pinned value before you need it. The most dangerous state is pinning a long-lived certificate that you have no way to revoke.
flowchart TB
accTitle: The decision flow when pinning
accDescr: Diagram showing the decision flow for an exception validation callback, which passes when there are no errors, rejects anything other than chain errors, pins the host name and thumbprint, and still rejects expiry and revocation in the chain status.
e0["Check errors"] -->|"None"| pass1["Allow"]
e0 -->|"anything other than chain errors"| rj1["Reject"]
e0 -->|"chain errors only"| hchk["Match the host name and thumbprint"]
hchk -->|"mismatch"| rj1
hchk -->|"match"| cchk["Check the chain status"]
cchk -->|"expired, revoked, and so on"| rj1
cchk -->|"only the internal CA not being installed"| pass1
Figure 14: Pinning still means not turning a blind eye - expiry and revocation are rejected outright.
3.7. Treat all external input as untrusted
Windows apps are not web apps, so input validation easily gets lax. But in reality, the entrances for external input are more numerous than you would think.
- File paths
- CSV / Excel / JSON / XML
- Command-line arguments
- Named pipes / sockets / COM / RPC / gRPC
- Strings passed to the DB
- Registry values
- The clipboard
- URLs / deep links
- Data returned from external devices or SDKs
The three you absolutely do not want to miss:
- Always parameterize SQL Never build SQL by string concatenation.
- Normalize file paths before using them Never use a user-supplied path directly for delete, overwrite, or extraction.
- Apply size limits and format checks when reading external files “It opened, so it’s safe” is not a thing.
For SQL, this is what you want to avoid:
var sql = "SELECT * FROM Users WHERE Name = '" + userName + "'";
At minimum, move it to this:
using System.Data;
using Microsoft.Data.SqlClient;
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT * FROM Users WHERE Name = @name";
cmd.Parameters.Add("@name", SqlDbType.NVarChar, 256).Value = userName;
“It’s an internal tool, so the input is trustworthy” is a genuinely dangerous premise. In reality, corrupted CSVs, unexpected file names, stale DB data, operator typos, and half-baked JSON written by other tools walk in all the time.
flowchart TB
accTitle: Broken input reaches internal tools too
accDescr: Diagram showing that even internal tools routinely receive corrupted CSV files, unexpected file names, stale DB data, typos, and half-baked JSON, so all external input has to be treated as untrusted.
csv1["Corrupted CSV"] --> in2["Input to the app"]
fn1["Unexpected file name"] --> in2
hm1["Typos and stale data"] --> in2
in2 --> tr2["Validate everything as untrusted input"]
Figure 15: Broken input reaches internal tools too, so validate at every entrance before use.
3.8. Never leave DLL load locations ambiguous
This is a distinctly Windows pitfall.
Load a DLL by name alone, like LoadLibrary("foo.dll"), and depending on the search order you may pick up a DLL from an unintended location.
The actions are well established.
- Specify the DLL’s absolute path where possible
- Set
SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS)early - Add explicit search locations with
AddDllDirectory - Avoid designs that pass
SearchPathresults straight intoLoadLibrary - Do not rely solely on safe DLL search mode
For native code, for example, putting this in early during process initialization is a strong pattern.
SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
Then register only the additional directories you need with AddDllDirectory.
Because it normally works, this area gets neglected - and then the working directory changes at a customer site, or another product’s DLL appears in PATH, and things break silently. Beyond security, this pays off considerably as failure prevention.
flowchart TB
accTitle: Pinning down where DLLs load from
accDescr: Diagram showing that loading a DLL by name alone lets the search order pick up a DLL from an unintended location, and the countermeasures of calling SetDefaultDllDirectories early, adding search locations explicitly with AddDllDirectory, and using absolute paths where possible.
nm1["Load by name alone"] --> pick2["The search order can pick up an unintended DLL"]
fix1["Call SetDefaultDllDirectories early"] --> add2["Add locations explicitly with AddDllDirectory"]
add2 --> abs1["Use an absolute path where possible"]
abs1 --> safe1["The load location is no longer ambiguous"]
Figure 16: Stop leaving loads to the name alone - state the search locations and pin down where DLLs come from.
3.9. Keep secrets out of logs and exceptions
Adding logs for incident investigation is important. But logs also make excellent graveyards for secrets.
The minimum review items around logging:
- Do not log passwords, Bearer tokens, or API keys
- Do not log entire connection strings
- Mask personal information and business data payloads
- Separate exception details between user-facing UI and internal logs
- Do not enable debug-grade PII logging in production
- Review the permissions on dump / trace storage locations
Recent .NET makes redaction-first design much easier. At the very least, stop “stringify everything and log it as is.”
A few classic failures:
- Persisting entire HTTP request / response bodies
- Dumping the token or full headers on authentication failure
- Showing raw exception messages in a MessageBox
- Bundling every sensitive log into the maintenance ZIP
Separate error presentation like this, for example:
- User-facing: “Failed to connect to the server. Please check your network settings and the URL.”
- Internal log: the host that failed, TLS error type, correlation ID, stack trace, retry count
This separation alone substantially improves the balance between leak prevention and diagnosability.
flowchart TB
accTitle: Splitting what an error reports
accDescr: Diagram showing that when an error occurs the user-facing message carries only short guidance while the internal log keeps the failing host, the error type, the correlation ID, the stack trace, and other diagnostic detail.
er1["An error occurs"] --> usr1["User-facing: short guidance only"]
er1 --> lg2["Internal log: host, error type, correlation ID, and so on"]
lg2 -.-> bl1["Prevents leaks while keeping diagnosability"]
Figure 17: Splitting the user-facing message from the internal log already balances leak prevention against diagnosability.
3.10. Don’t neglect dependencies and tooling
The last item is unglamorous but high-impact. Build the app body carefully, but ship it on top of an old runtime or dependencies with known vulnerabilities, and the floor gives way.
The list of things to watch is actually short.
- Keep the .NET SDK / runtime on supported versions
- Periodically review NuGet / OSS dependency updates
- For C++, version-manage runtime redistributables and external DLLs
- Add vulnerability-report checks to the pre-release checklist
- Maintain smoke tests so dependency updates do not break you silently
“We’ll batch it up later” is the most dangerous stance here. Let it sit for six months or a year, and the update delta grows so large that the security work itself becomes a heavy project.
flowchart TB
accTitle: What neglecting dependency updates leads to
accDescr: Diagram showing that letting dependency updates sit for six months to a year makes the update delta too large, which turns the security work itself into a heavy project.
pt1["We will batch it up later"] --> ac2["Six months to a year of neglect"]
ac2 --> df1["The update delta grows too large"]
df1 --> hw1["The work itself becomes a heavy project"]
rg1["Review it regularly"] -.->|"avoids this"| hw1
Figure 18: The longer dependency updates sit, the larger the delta grows and the heavier the work becomes.
3.11. How to verify each item
A checklist only works when it comes with a way to verify each item. For the items in 3.2 through 3.10, here are checks you can actually run before release.
| What you want to verify | How to verify it |
|---|---|
| Whether the app requests elevation | Look at the requestedExecutionLevel value in the app manifest. From source that is app.manifest; for a shipped artifact, check it with Sysinternals Sigcheck or a resource editor |
| Signature and timestamp | Run Get-AuthenticodeSignature .\app.exe in PowerShell and check that Status is Valid and that TimeStamperCertificate is populated. Check the bundled DLLs and the updater one by one as well |
| Whether certificate validation has been killed | Search the whole source for ServerCertificateValidationCallback, DangerousAcceptAnyServerCertificateValidator, ServerCertificateCustomValidationCallback, and CheckCertificateRevocationList |
| Hard-coded secrets | Search for Password=, ApiKey, Secret, Token, and ConnectionString. Cover not just the current source but the repository history too |
| How SQL is assembled | Search for string concatenation containing "SELECT, "INSERT, and + , then confirm it goes through Parameters.Add |
| Where DLLs load from | In Process Monitor, narrow to the target process and apply the filters Path ends with .dll and Result is NAME NOT FOUND. You can see where it looked and in what order, so you can confirm it is not searching unintended folders |
| Known vulnerabilities in dependencies | Run dotnet list package --vulnerable --include-transitive |
| Whether secrets show up in logs | Run the app once, then search the emitted logs for Bearer , Password, and Authorization |
Code search can be rg (ripgrep) or the Visual Studio search - either is fine. To run them together, this form works.
Get-ChildItem -Recurse -Include *.cs,*.vb,*.cpp,*.h,*.config,*.json |
Select-String -Pattern 'ServerCertificateValidationCallback|DangerousAcceptAnyServerCertificateValidator|Password=|ApiKey' |
Select-Object Path, LineNumber, Line
What matters is recording the fact that you checked. Keep the “we searched” and “zero hits” notes, and the next release only needs the diff.
flowchart TB
accTitle: Why recording the checks pays off
accDescr: Diagram showing that recording the pre-release checks even when they return zero hits means the next release only needs to look at the diff.
chk2["Run the checks before release"] --> rec1["Record that you searched and got zero hits"]
rec1 --> nx1["The next release only needs the diff"]
Figure 19: Record the fact that you checked, and from the next release on you only review the diff.
4. Pre-Release Checklist
Here it is in a form you can use directly as a template for reviews and ship/no-ship decisions. For easy verification in table form, the minimum pre-release items are arranged by category.
4.1. Privileges and execution model
| Check item | Done | Notes |
|---|---|---|
Normal startup runs as asInvoker |
□ | |
| Operations requiring admin rights are isolated into a separate EXE / service | □ | |
| If a service is used, its account is no stronger than necessary | □ | |
Responsibilities under %ProgramFiles% and under user data are separated |
□ |
4.2. Distribution and signing
| Check item | Done | Notes |
|---|---|---|
| EXE / DLL / MSI / MSIX / updater are signed | □ | |
| Signatures carry timestamps | □ | |
| Certificate expiry and renewal are part of the release flow | □ | |
| Hash verification / tamper detection for artifacts is defined | □ |
4.3. Updates
| Check item | Done | Notes |
|---|---|---|
| Updates are fetched over HTTPS | □ | |
| Signature or hash is verified after download | □ | |
| The design makes it hard to swap the update source URL arbitrarily | □ | |
| A rollback or retry policy exists for failed updates | □ |
4.4. Secrets
| Check item | Done | Notes |
|---|---|---|
| No passwords, API keys, or connection strings hard-coded in source | □ | |
| No secrets in plaintext config files | □ | |
| Secrets that must be stored locally are protected with DPAPI / Credential Locker etc. | □ | |
| Windows authentication or user credentials are used where possible | □ |
4.5. Communication
| Check item | Done | Notes |
|---|---|---|
| Production traffic uses HTTPS | □ | |
No DangerousAcceptAnyServerCertificateValidator or => true left in shipped builds |
□ | |
| Revocation checking and hostname validation are accounted for | □ | |
| No code or settings assuming development certificates mixed into production | □ |
4.6. Input and data access
| Check item | Done | Notes |
|---|---|---|
| SQL is parameterized | □ | |
| Command-line, file, IPC, and URI inputs have size limits and format checks | □ | |
| Path operations are normalized and root escape is prevented | □ | |
| Raw exception messages are not shown directly on screen | □ |
4.7. DLLs and the execution environment
| Check item | Done | Notes |
|---|---|---|
| DLL load locations are explicit | □ | |
Search order is controlled via SetDefaultDllDirectories / AddDllDirectory etc. |
□ | |
| No DLL loading left to the current directory or PATH | □ | |
| The full set of files needed for dynamic loading at deployment sites is understood | □ |
4.8. Logging and operations
| Check item | Done | Notes |
|---|---|---|
| No tokens, passwords, or PII in logs | □ | |
| Internal logs and user-facing messages are separated | □ | |
| Permissions on dump / trace / log storage locations have been reviewed | □ | |
| SDK and dependency update status is being checked | □ |
5. Common Anti-Patterns
What we most often see in practice are assumptions like these.
5.1. “It’s an internal tool, so it’s fine”
Internal tools still face corrupted files, operator mistakes, personal devices, shared folders, stale DLLs, and sloppy permission settings. Not being exposed to the internet does not erase the attack surface.
5.2. “It’s HTTPS, so it’s secure”
HTTPS matters, but disabling certificate validation hollows out most of its meaning. And for update distribution, you need not just HTTPS but verification of artifact authenticity.
5.3. “It’s encrypted, so it’s safe”
Without sorting out where the decryption key lives, who can decrypt, and the user and machine boundaries, encryption alone is not enough.
In particular, using a LocalMachine-protected value as if it were a per-user secret leads to confusion later.
5.4. “More logs means easier investigation”
If the logs are voluminous but tokens and personal information pour through them, the logs themselves become the incident. If you want diagnosability, first decide what to keep and what to redact.
5.5. “Just run it as admin and the problem goes away”
Easy at first - and later it hurts in UAC, distribution, support, privilege boundaries, DLL loading, and file storage locations. Least privilege is more stable over the long run.
flowchart TB
accTitle: Where just run it as admin ends up
accDescr: Diagram showing that habitually running with administrator rights is easy at first but later hurts in UAC, distribution, support, privilege boundaries, DLL loading, and file storage locations, and that least privilege is more stable over the long run.
ez1["Just run it as admin"] --> ez2["Easy at first"]
ez2 --> pain1["Hurts later in UAC, distribution, and support"]
ez2 --> pain2["Hurts later in privilege boundaries and storage locations"]
lp1["Run with least privilege"] -->|"stable over the long run"| ok2["Avoids the pain"]
Figure 20: Running as admin is only easy at the start - over the long run least privilege is the stable choice.
6. Rough Priorities
If doing everything at once is too heavy, the priorities run roughly like this.
The ordering multiplies how large the damage is when something goes wrong by how cheap the fix is. Section 3 is arranged by design flow - privileges, then distribution, then implementation, then operations - while this list runs from the most dangerous down, so the order does not match the section order. The matching sections are noted alongside.
- Review administrator privileges (3.2)
First, stop habitually using
requireAdministrator. It shifts the blast radius by a whole level, and it is often a small design change. - Signing and timestamps (3.3) Put the trustworthiness of your artifacts in order. It is just a matter of building it into the procedure, and adding it later means redistributing.
- Move the secrets out (3.5) Get secrets out of source code and plaintext config. The damage when they leak is large, and once leaked there is no taking it back.
- Fix HTTPS + certificate validation (3.6)
Remove the
=> truefamily from shipped builds. Deleting the code often fixes it outright, and leaving it means none of your traffic can be trusted. - Review SQL / file / IPC input (3.7) Reduce string concatenation and unvalidated input. There are many occurrences, but you can fix them one at a time.
- Pin down DLL loading (3.8) Stop name-only loads and PATH dependence. It is a change to part of the startup path, and it prevents failures too.
- Mask the logs (3.9) Make sure logs do not become a secondary disaster during an incident. There are many output sites, so it takes time.
- Make dependency updates routine (3.10) Build the check into every release. It never finishes in one pass, so the work is turning it into a mechanism.
The update channel (3.4) is missing from this list because some apps have no auto-update at all. If yours does, give it the same priority as signing at number 2. The update module is weaker than the app body, yet it is in a position to rewrite it.
flowchart TB
accTitle: How to set the priorities
accDescr: Diagram showing that priorities come from multiplying how large the damage is by how cheap the fix is, working from the most dangerous down, and that apps with auto-update should give the update channel the same priority as signing.
dmg1["How large the damage is"] --> mul1["Rank by the combination"]
cst1["How cheap the fix is"] --> mul1
mul1 --> ord1["Plug the most dangerous holes first"]
ord1 -.-> upn1["With auto-update, the update channel ranks with signing"]
Figure 21: Priorities come from damage multiplied by cost to fix, and you plug the dangerous holes first.
In this order, you can proceed in the spirit of plugging the obviously dangerous holes first.
7. Summary
Before introducing special products or massive frameworks, Windows app security changes considerably just by putting these seven things in order: privileges, signing, secrets, communication, input, DLLs, and logging.
The minimum bar, one line each:
- Do not run the whole app with administrator privileges
- Sign your artifacts and updates, with timestamps
- Keep secrets out of source code and plaintext config
- Use HTTPS - and do not kill certificate validation
- Do not trust external input: SQL, files, IPC, and the rest
- Never leave DLL load locations ambiguous
- Keep secrets out of logs
- Do not neglect your dependencies
Security is a broad subject, but you do not have to do all of it from day one. The one minimum worth establishing very early, though, is this: never ship dangerous defaults as they are.
8. References
- Administrator Broker Model - Win32 apps
- How User Account Control works
- Authenticode Digital Signatures
- Time Stamping Authenticode Signatures
- Sign a Windows app package
- Credential Locker for Windows apps
- CryptProtectData function (dpapi.h)
- CA5359: Do not disable certificate validation
- CA5399: Enable HttpClient certificate revocation list check
- Configuring parameters - ADO.NET Provider for SQL Server
- Connection String Syntax - ADO.NET
- Dynamic-Link Library Security - Win32 apps
- SetDefaultDllDirectories function (libloaderapi.h)
- Data redaction in .NET
- ProtectedData Class - Windows-only, plus the caveat about the profile not being loaded.
- ServicePointManager.ServerCertificateValidationCallback - Mapped to SocketsHttpHandler settings on .NET 9 and later.
- dotnet list package command -
--vulnerablereports known vulnerabilities. - Get-AuthenticodeSignature
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...
Storing Secrets in Windows Apps - Avoiding Plaintext Configuration with DPAPI
To avoid storing connection credentials and API tokens in plaintext configuration files in Windows apps, we walk through DPAPI / Protecte...
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...
A Decision Table for Whether to Exit or Continue After an Unexpected Exception
When an unexpected exception occurs, should the app exit or keep running? We organize the decision from the perspectives of state corrupt...
Named Pipes in Practice — Windows' Standard IPC from Design to Security
A practical guide to named pipes, Windows' standard inter-process communication. This article organises, from primary sources, the choice...
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
Reviewing a Windows application end to end - privilege design, distribution method, update mechanism, and logging design - is a natural fit for our Windows application development service.
Technical Consulting & Design Review
If you want to start with a security review of an existing app, sorting out privilege boundaries, or redesigning your updater policy, we can structure that as technical consulting and design review.
Frequently Asked Questions
Common questions about the topic of this article.
- What should you look at first in Windows app security?
- Four things: do not request administrator privileges you do not need, code-sign your binaries, do not keep secrets in plaintext, and do not disable certificate validation. Minimum security is less about adding special features and more about leaving no dangerous defaults or sloppy implementations in place. Unconditionally skipping certificate validation, plaintext connection strings, DLL loading left to the current directory, and SQL executed from concatenated strings are all items to avoid even at the minimum bar.
- Is it wrong to run the whole app with administrator privileges?
- It is something to avoid. When the whole app runs with administrator rights, bugs, DLL substitution, misread config files, and unvalidated external input all execute with those strong privileges. The basic policy is to default ordinary UI apps to asInvoker, split only the operations that need administrator rights into a separate process or service, and elevate only for the moments that require it.
- Where should API keys and connection strings be stored?
- The first state to escape is keeping them in plaintext in source code or in config files such as appsettings.json. For secrets at rest, choose between DPAPI / ProtectedData and the Credential Locker depending on the use case. Leaving tokens, passwords, connection strings, or personal information in logs as is makes the log itself the thing that leaks, so masking is needed as well.
- Do internally distributed apps need code signing too?
- Treat it as a given. In a Windows app, the distribution artifacts themselves - EXE / DLL / MSI / MSIX / auto-update modules - are the attack surface. Code signing plus a timestamp buys you tamper detection, trust for your users, and an easier operational story. The update mechanism should also pin the update source so that tampering can be detected through HTTPS and signature verification.