---
title: "The stale credential that failed two different code paths at once"
canonical: https://dxdev.com/blog/stale-credential-failed-two-code-paths-at-once/
datePublished: 2026-06-03
---
# The stale credential that failed two different code paths at once

A sandbox checkout died with "merchant login ID or password is invalid." The exact same credentials had authenticated cleanly in a standalone Python proof minutes earlier, returning `I00001`. Same gateway, same login, same machine, and one path said "yes" while the other said "your password is wrong." That contradiction is the whole story, and the resolution is a gotcha worth internalizing: when the "same" credential works in one tool and fails in another, you are almost certainly reading two different copies of it.

## The setup

I was building a save-card feature for the site's checkout: let a customer store a card at checkout and re-use it next season as a one-click option, with the card held by the payment provider (Authorize.Net) and never stored on our side. The mechanism is CIM, Authorize.Net's Customer Information Manager. You create a customer profile, get back gateway token IDs, and re-charge later with only those tokens, no PAN.

Before I wrote a line of UI, I proved the full happy path against the Authorize.Net sandbox from Python: authenticate, `createCustomerProfile`, read it back for display, then charge by token. It worked. Profile created, charge approved, no card number anywhere in the flow. Green.

Then I wired the same capability into the actual checkout running under IIS on a local clone and ran it through the browser. That is where it failed with the invalid-login error.

## Two copies of the truth

The site runs as several near-identical local clones: `local clone 1`, `local clone 2`, `local clone 3`, and so on, each its own checkout served by its own IIS site (`l2` serves `local clone 2`'s branch). Each clone loads its own environment from its own gitignored `.env-local`. That is the trap, and it is invisible until it bites you.

My Python proof read credentials from the primary local clone's `.env-local`. The checkout I was testing in the browser was `l2`, which loads `local clone 2`'s env. Both clones pointed at the same Authorize.Net sandbox and shared the same sandbox API login ID, which is exactly why the symptom was so disorienting. The login was identical. What had drifted was the transaction key: `AUTHNET_TRAN_KEY_TEST`. `local clone 1` carried a working key (authenticates, `I00001`). `local clone 2` carried a stale one (rejected, `E00007 User authentication failed`).

So the credential was never "the same credential failing nondeterministically." It was two different secrets that happened to share a login ID, and I was reading whichever one the runtime under test happened to load. The Python proof and the IIS checkout were never reading the same file.

This is the per-environment-config drift problem in its purest form. The moment a secret lives in N copies, one of them goes stale, and the failure surfaces as "but it works over here," which sends you hunting in the wrong place.

## Why it looked like two separate bugs

Here is the part that made it genuinely confusing instead of merely annoying. Authorize.Net has a legacy charge API (the old AIM-style direct charge) and the modern XML API that CIM tokenization rides on. They are different code paths, different request shapes, different parsers. But they authenticate with the same transaction key.

So one stale key failed both legs at once. The legacy charge rejected the credentials with a plaintext "the merchant login ID or password is invalid" from the old gateway. The modern tokenized CIM call rejected the same credentials too, this time with the structured `E00007 User authentication failed` from the XML API. Different error shapes, same cause. Two distinct features, both broken, both at the same time, from a single root cause. For a while it looked like I had a charge bug and a separate tokenization bug, two problems to chase. There was one problem wearing two costumes.

The lesson generalizes past payment gateways: when a single secret feeds multiple code paths and that secret goes bad, you get a multi-symptom failure that masquerades as several unrelated bugs. Count your shared inputs before you assume you have several independent faults. If two failing paths share exactly one upstream dependency, suspect the dependency, not the paths.

## The blank value that clobbered a real one

While I was untangling this, a second, smaller gotcha compounded it, and it is one I have hit before in other shapes. The env loading merges files in order: `.env-dev` first, then `.env-local` on top. `.env-local` had an *empty* assignment, `AGENT_USERNAME=`, sitting there with no value. That blank silently overrode the perfectly good value that `.env-dev` had already set.

So one of my early hypotheses, "the creds are just empty," was wrong in a specific and instructive way. The creds were not missing. A real value existed in the earlier file. A later-loaded file declared the same key with nothing after the equals sign, and the merge treated "declared as empty" the same as "set to empty," stomping the good value. An empty assignment is not a no-op. It is an explicit "set this to nothing," and last-write-wins means it beats a real value loaded earlier.

If your config layering does last-write-wins (most do), an empty value in a higher-priority file is a live landmine. It does not look like a value, so you skip past it when reading the file. It behaves like a value, so it wins the merge.

## The fix

The fix itself was small once the diagnosis was right: sync the working transaction key into `local clone 2`'s gitignored `.env-local` so `l2` loaded the key that actually authenticates. The clone reads `.env-local` per request (`EnvGet` is per-request, not cached at app start), so the corrected key took effect live without an IIS restart, which I confirmed by re-running the checkout immediately.

The durable part was the note, not the edit: if `local clone 2` had drifted, the other clones almost certainly carry the same stale key, so the real fix is reconciling the secret across all of them rather than patching the one clone in front of me. One stale copy is rarely alone.

One more thing worth flagging about these clones specifically, because it caught me adjacent to this: the `l<N>` local IIS clones share the *production* database via an ODBC DSN. "Testing on l2" isolates the rendered HTML and the sandbox gateway, but it does not isolate data. That is a separate trap from the credential drift, and it is the kind of thing you want pinned down before you run anything that writes.

## The takeaway

When a credential works in one tool and fails in another with the same login, stop debugging the credential and start asking *which copy of it each tool is reading.* The contradiction is the diagnosis. Identical-looking secrets that drift per environment fail in confusing, multi-symptom ways, and they fail worst when one secret feeds several code paths, because a single stale value lights up as several apparently unrelated bugs.

Two concrete rules fall out of this:

- If two failing paths share exactly one upstream input, suspect the input before you suspect the paths. Count shared dependencies before you assume you have N independent bugs.
- In layered config, an empty value in a later-loaded file silently beats a real value from an earlier one. "It's empty" can mean "a blank clobbered it," not "it was never set." Grep every file in the load order for the key, not just the one you expect to own it.

## Related

- [Same Root Cause Is Not Same Fix: Verify the Data Shape, Not Just the Symptom](same-root-cause-is-not-same-fix-verify-the-shape): when two bugs share a root cause but need different fixes, the same pattern as two code paths sharing one stale credential
- [The runtime was half dead: use a migration as the audit you'd never run](the-runtime-was-half-dead-migration-as-audit): a different "works somewhere, fails here" multi-symptom postmortem
- [Three compounding bugs spawned a burst of near-identical tickets, and `tail -15` hid the evidence](tail-15-hid-the-success-line-and-caused-an-infinite-retry): another case where independent-looking failures had a shared upstream cause
- ["My internet feels unstable" was a half-disabled IPv6 stack, not the ISP](ipv6-half-disabled-stack-not-the-isp): confident wrong diagnosis before reading the actual layer at fault
- ["Bamboo is broken" was wrong: a deploy that races the filesystem under CPU pressure](bamboo-not-broken-deploy-races-filesystem-under-cpu-pressure): another multi-symptom failure where the first read of the evidence pointed at the wrong cause
