Moving long-lived credentials into a managed secret store is a good security change. It is also easy to stop that migration too early.
The uncomfortable question is not, “Do we have a secure store?” It is, “Can this credential still travel through any other path?”
A recent C# and ASP.NET Core hardening change brought that distinction into focus. The intended store was secure, but older disk-backed helpers—including an SDK-backed file cache—still existed. The destination had improved; the authority model had not yet become singular.
The migration illusion
OAuth integrations often accumulate several generations of code:
- a first version that writes a token file;
- a later service that saves a reference to a protected or managed secret;
- a legacy SDK helper configured with its own file-backed cache;
- a compatibility controller or helper that can still read the old files; and
- an environment fallback added to keep development convenient.
Each part can look reasonable in isolation. Together, they create multiple credential authorities.
That matters because an attacker, operator, or future maintainer does not care which path the architecture diagram calls canonical. If an older reader can authenticate with a disk file, or a library can write a fresh cache beside the managed store, that alternate path is part of the real boundary.
Security posture follows the weakest active route.
Define one credential authority
A useful target state is closed-world: every credential read and write is accounted for, and anything outside the approved store is rejected.
For a server-side integration, that might mean:
- Resolve client configuration directly from the configuration providers and parse it in memory.
- Exchange authorization codes without allowing the SDK to persist tokens.
- Store refresh tokens only through the application-owned credential service.
- Persist an opaque reference and provider metadata, never the plaintext token.
- Retrieve and retire the token through that same service.
The key phrase is “only through”. A secure store is not merely another destination. It is the sole authority.
Close SDK persistence explicitly
Third-party SDKs often provide convenient persistence helpers. Those are useful in a desktop sample and risky in a multi-user server process.
Make the decision visible in every active flow:
var flow = new AuthorizationFlow(new AuthorizationFlowOptions
{
ClientConfiguration = ResolveInMemory(configuration),
CredentialStore = new NoOpCredentialStore()
});
The names here are deliberately generic, but the principle is concrete: do not leave persistence semantics implicit or assume another layer owns them. Supply the no-op implementation where the SDK accepts its persistence dependency.
Then enumerate all flows. Initial connection, token refresh, and API-client construction may each build their own authorization object. Fixing two out of three still leaves a second authority.
Delete the old path
Deprecating a credential path in a comment is not the same as removing it.
Delete obsolete file readers, writers, controllers, helper services, configuration keys, and package references. Remove their dependency-injection registrations. Stop copying old credential assets into build output. If a compatibility period is unavoidable, give it an owner, an expiry condition, and telemetry that proves whether it is still used.
Deletion reduces the proof surface. Reviewers can reason about what exists instead of relying on a promise that reachable code “should no longer run”.
Test that the forbidden path stays absent
Behaviour tests are essential, but some architecture regressions are difficult to trigger naturally. A convenience helper could be restored with file-backed caching while happy-path authorization tests remain green.
This is one of the rare cases where a focused structural guard is useful. It can:
- take a non-empty census of the relevant production sources;
- require every known authorization flow to opt into the no-op store;
- reject known disk-store types, filenames, and helper calls; and
- assert that retired endpoints and directories remain absent.
The non-empty census matters. A source scan that finds zero files can pass while proving nothing.
Pair that guard with behavioural tests. Place a plausible legacy credential file where old code would have found it and prove the application ignores it. Simulate a managed-store write failure and prove the operation fails without persisting plaintext. For a development-only protected fallback, prove stored material differs from the original token and can be recovered only through the protector.
Structural tests protect the shape; behavioural tests protect the outcome.
Fail closed by environment
Development and production do not need identical storage mechanisms, but both need explicit policy.
A local environment can use a protected, developer-owned store when a managed service would make ordinary work impractical. That fallback should be named and enabled deliberately. In non-development environments, the default should be no fallback: if the managed store is unavailable, the connection attempt fails.
That is an operational trade-off. Availability decreases during a store outage. The alternative is worse: a silent downgrade that creates a credential copy in a less controlled location precisely when the primary control is unavailable.
The engineering trade-off
One authority means stricter configuration, fewer emergency shortcuts, more failure-path tests, and a migration that touches more than the storage class. It may expose outages that a permissive fallback previously hid.
In return, incident response becomes clearer. There is one place to rotate, audit, revoke, and monitor. A failure cannot quietly change the protection level. Future maintainers have a smaller state space to understand.
Before calling a credential migration complete, draw every reader and writer—including SDK helpers. If you cannot point to the single authority for each environment, the boundary is still open.
Top comments (0)