A Minimum Security Checklist for Windows App Development

· Updated: · · Windows Development, Security, Design, C# / .NET, Win32

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.

How this article proceedsDiagram 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.before thatAdvanced defenses such as zero trustPlug the gaps in the basicsDesignImplementationDistributionOperations

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 like LoadLibrary("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.

What minimum security meansDiagram showing that minimum security is not about adding special features but about leaving no dangerous defaults or sloppy implementations in place.not the point of the minimum barthis is the pointAdd special featuresMinimum securityLeave no dangerous defaults or sloppy implementations

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.

Minimum security checklist for Windows appsDiagram 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 logsconfigured byconfigured bynot recommended forrecommended formay causemay causepreventsmitigatespreventsrecommended forrecommended forrequiresnot recommended formay causepreventsmay causerecommended forrequiresusesrequiresrecommended forAdministrator PrivilegesCode signing certificateApplication SecretsasInvoker Execution LevelrequireAdministrator Execution LevelInteractive-user desktop appAdmin Privilege SeparationDLL Search Order HijackingName-Only DLL LoadingUnsigned BinaryCode signing timestampCertificate ExpirationUpdate Signature and Hash VerificationApplying Unverified UpdatesCertificate PinningUnconditional Certificate Validation BypassCertificate Revocation CheckSQL String ConcatenationSQL InjectionPrepared Statements (Placeholders)Writing Secrets to LogsSecret Exposure Through LogsDPAPIPlaintext Secret StorageWindows Service

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.

What minimum means in this articleDiagram 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.not what we mean herethis is what we meanFinal state that passes an auditThe minimum in this articleItems that cause incidents when missing

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.

Where the scope line is drawnDiagram 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.not covered herethis is what we coverMassive organization-wide programsScope of this articleBaseline developers struggle to cover alone

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.

The danger of elevating the whole appDiagram 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.Run the whole app with administrator rightsBugsDLL substitutionMisread config and unvalidated inputExecutes with strong privileges as is

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.

Isolating only the operations that need elevationDiagram 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.requests only when neededUI app (asInvoker)broker (separate EXE / service)Runs only the operations that need elevationValidate 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.

What users touch is the distribution artifactDiagram 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.What users touchEXE / DLLMSI / MSIXUpdaterUnsigned weakens tamper detection and the explanation

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.

Signing and timestampingDiagram 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.Signature onlyValidation gets awkward once the certificate expiresSignature + timestampValidation still holds up after expiryBuild 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.

The update channel gets the most useDiagram 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.used onceused far longerInitial installLifetime of the appUpdate channelSloppy 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.

Two things to verify during an updateDiagram 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.Fetch the update file over HTTPSVerify the signature or hashApply only what passes verificationHTTPS protects only the transportConfirms 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.json or app.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.”

Who can decrypt with DPAPIDiagram 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.CurrentUserLocalMachineDecide in the design who can decryptOnly the saving user can decryptAnyone on the same machine can decryptSkip 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.

The DPAPI key and the user profileDiagram showing that DPAPI keeps its key in the user profile, so decryption fails when the profile is not loaded, such as during impersonation.profile not loaded, for example during impersonationThe DPAPI keyLives in the user profileDecryption failsCheck 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 += ... => true
  • HttpClientHandler.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.

How an old callback still reaches current trafficDiagram 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.mapped on .NET 9 and laterThe ServicePointManager validation callbackValidation on the SocketsHttpHandler sideApplies to HttpClient traffic tooA 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.

The decision flow when pinningDiagram 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.Noneanything other than chain errorschain errors onlymismatchmatchexpired, revoked, and so ononly the internal CA not being installedCheck errorsAllowRejectMatch the host name and thumbprintCheck the chain status

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:

  1. Always parameterize SQL Never build SQL by string concatenation.
  2. Normalize file paths before using them Never use a user-supplied path directly for delete, overwrite, or extraction.
  3. 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.

Broken input reaches internal tools tooDiagram 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.Corrupted CSVInput to the appUnexpected file nameTypos and stale dataValidate 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 SearchPath results straight into LoadLibrary
  • 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.

Pinning down where DLLs load fromDiagram 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.Load by name aloneThe search order can pick up an unintended DLLCall SetDefaultDllDirectories earlyAdd locations explicitly with AddDllDirectoryUse an absolute path where possibleThe 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.

Splitting what an error reportsDiagram 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.An error occursUser-facing: short guidance onlyInternal log: host, error type, correlation ID, and so onPrevents 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.

What neglecting dependency updates leads toDiagram 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.avoids thisWe will batch it up laterSix months to a year of neglectThe update delta grows too largeThe work itself becomes a heavy projectReview it regularly

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.

Why recording the checks pays offDiagram showing that recording the pre-release checks even when they return zero hits means the next release only needs to look at the diff.Run the checks before releaseRecord that you searched and got zero hitsThe 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.

Where just run it as admin ends upDiagram 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.stable over the long runJust run it as adminEasy at firstHurts later in UAC, distribution, and supportHurts later in privilege boundaries and storage locationsRun with least privilegeAvoids 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.

  1. 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.
  2. 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.
  3. 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.
  4. Fix HTTPS + certificate validation (3.6) Remove the => true family from shipped builds. Deleting the code often fixes it outright, and leaving it means none of your traffic can be trusted.
  5. 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.
  6. 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.
  7. 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.
  8. 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.

How to set the prioritiesDiagram 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.How large the damage isRank by the combinationHow cheap the fix isPlug the most dangerous holes firstWith 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

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

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.

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.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog