Skip to main content

Platform Secrets & Cross-Service API-Key Reference

Status: Reference document, kept current against real appsettings.Development.json files reviewed 2026-09-26 for Keystone, Command Center, and RoundTrip. Not a design document — nothing here is a decision, it's a map of what already exists so it doesn't have to be re-derived from scratch every time. Purpose: answer two recurring questions in one place — "what secrets does project X need locally?" and "when two services talk to each other, which config key on which side holds the shared value, and why do they have different names?" Written so a future engineer (including future-you) can read this cold.


1. The naming pattern​

This is the one idea that makes the whole system legible, so it goes first.

Every service-to-service API key is named from the point of view of the config file it lives in — not from a shared, neutral name. The sender names it "the key I send to X." The receiver names it "the key I expect from Y." Same value, two different names, because each file is only describing its own role in the relationship.

Confirmed, real example — the Keystone ↔ Command Center pair:

WhereConfig keyWhat it means from that file's own point of view
Command Center (appsettings.Development.json)Keystone:ApiKey"the key I attach when I call Keystone"
Keystone (appsettings.Development.json)ApiKeys:CommandCenter"the key I expect to see when Command Center calls me"

Both hold the identical real value: G8roR020XpdnXrM4Q52no49I0fuf3+8j3eXX+lqPm3Q=. It's sent as the X-Keystone-ApiKey header on the request and checked with a constant-time comparison on the receiving side (see §5).

Once this clicks, the rest of the config stops looking like an arbitrary pile of similarly-named keys and starts reading as a set of directed edges: A:ApiKey in A's config paired with ApiKeys:A (or A:ApiKey again, whichever that receiver's own convention is) in the callee's config.

Generating a new one of these locally — the command referenced but not recalled in the prior conversation is simply:

openssl rand -base64 32

Generate it once, then paste the identical value into both sides (sender's outgoing key, receiver's expected key). There's no key-exchange step because both are developer-local files edited by the same person.


2. Real call graph today​

  • Command Center → Keystone: confirmed both sides, real value shown above.
  • RoundTrip → Command Center: confirmed to exist (RoundTrip's CommandCenter:ApiKey-shaped config), matching value not independently re-verified against Command Center's receiving-side key name in this pass.
  • Command Center → RoundTrip: exists for the tenant-admin provisioning call (POST /v1/tenant-admin/provision); exact config key name on RoundTrip's receiving side not confirmed in this pass.
  • Waypoint is deliberately absent from this graph — it has no service-to-service keys yet. Its Keystone migration will add some (§4).

3. Per-project secrets​

Each table only lists what's local, developer-set config (dotnet user-secrets / appsettings.Development.json), not what's baked into code or handled by cloud infrastructure. Confirmed = read directly from a real file in this session. Unconfirmed = referenced or inferred but not seen directly.

Keystone​

KeyPurposeStatus
ConnectionStrings:DefaultConnectionLocal SQL Server / Postgres connection for Keystone's own DBConfirmed
ApiKeys:CommandCenterExpected key on inbound calls from Command CenterConfirmed — real value above
OpenIddict signing/encryption materialToken issuanceHandled by OpenIddict's own local dev-cert story, not a plain secret in this file — no action needed locally

Keystone deliberately has the shortest list of any project — it owns credentials and nothing else.

Production note: this table is local-dev only, per this document's own stated scope. Keystone's real Azure Key Vault/App Settings (four active secrets, plus a deliberately-unconfigured external-login pair with a real incident behind that decision) are documented separately in keystone-production-secrets.md.

Command Center​

KeyPurposeStatus
ConnectionStrings:DefaultConnectionLocal DB connectionConfirmed
Keystone:ApiKeyKey sent to Keystone on outbound callsConfirmed — real value above
CommandCenter:ApiKey-equivalent (expected key from RoundTrip)Key Command Center expects when RoundTrip calls itConfirmed to exist; exact key name not re-verified this pass
SendGrid:ApiKeyOutbound transactional emailConfirmed present, real-looking value; confirmed gitignored by Pete directly
Stripe keys (secret key, webhook signing secret)Not yet — Stripe integration lives in RoundTrip today, migration to Command Center is designed but not built (see keystone-signup-orchestration.md §3)N/A yet — will be added when that migration happens

RoundTrip​

KeyPurposeStatus
ConnectionStrings:*Uses Azure Key Vault references (@Microsoft.KeyVault(VaultName=...;SecretName=...)), not plain local secretsConfirmed — different pattern than the other three projects
AzureAd:* (six values)Entra CIAM authConfirmed present, each marked "** set via user-secrets **" — real values not stored in the checked-in file, by design
Seq:*Local logging sinkConfirmed present, same user-secrets-only marker pattern
CommandCenter:ApiKeyKey sent to Command CenterConfirmed to exist
Stripe secret key + webhook signing secretRoundTrip's current, real, working Stripe integrationLocation not confirmed. Not seen in the reviewed appsettings.Development.json — either Key-Vault-referenced elsewhere (consistent with RoundTrip's connection-string pattern) or genuinely not needed locally for the current dev workflow. Worth a direct answer before assuming either way.

Note on RoundTrip's pattern: it's the only project using Key Vault references instead of plain user-secrets. That's an existing, established RoundTrip convention, not something to reconcile with the other three projects — just worth remembering when RoundTrip's own eventual Keystone migration happens, since its secrets story looks structurally different going in.

Waypoint​

KeyPurposeStatus
ConnectionStrings:DefaultLocal DB connection — note the key name, it's Default, not DefaultConnection like the other three projects. Easy to miss; a typo'd key here binds to an empty string instead of erroring.Confirmed
GraphApi:ClientSecretMicrosoft Graph API access (guest-invite flow, tenant claim resolution)Confirmed present, real-looking value. Flagged separately in waypoint-auth-review.md §2 as worth confirming it's actually gitignored and rotating regardless, since it was shared outside the local machine once already.
AzureAd:*Entra CIAM auth — shares the same CIAM tenant instance as RoundTrip (https://roundtripapp.ciamlogin.com/), which is the root cause of WAY-49Confirmed — becomes irrelevant once Waypoint migrates to Keystone

Waypoint currently needs no Keystone/Command Center keys — it doesn't talk to either yet. That changes with its migration (§4).


4. What Waypoint's migration eventually adds​

Two new service-to-service relationships that don't exist yet, per keystone-signup-orchestration.md §7 and waypoint-keystone-migration-design.md:

  1. Command Center → Waypoint, for provisioning (the equivalent of the existing Command Center → RoundTrip tenant-admin call).
  2. Waypoint → Command Center, for an ownership/entitlement check (Waypoint asking "does this tenant actually own me").

Important, stated plainly so it doesn't get shortcut under time pressure: these need their own new key pairs. Do not reuse the existing Keystone↔Command Center key for either relationship, even though it would technically "work" during early testing — that's exactly the kind of shared-secret reuse that turns "rotate one compromised key" into "rotate every relationship that key touches." Generate a fresh value with openssl rand -base64 32 for each new directed edge, same as every existing pair.


5. General security notes worth keeping in mind​

  • One shared secret per relationship, never reused across two. Each arrow in the diagram in §2 gets its own value. If Waypoint's future calls to Command Center ever used the same key RoundTrip uses, compromising or needing to rotate one forces rotating both — there's no way to revoke just one caller's access without breaking the other.
  • Constant-time comparison on the receiving side. Keystone's real ApiKeyAuthenticationHandler compares the incoming header against the expected value using CryptographicOperations.FixedTimeEquals, not == or string.Equals. A naive string comparison leaks timing information about how many leading characters matched, which is a real (if narrow) side-channel for guessing a secret byte-by-byte. Any new API-key check added for the Waypoint relationships in §4 should follow the same pattern.
  • Scope database credentials properly if sharing a SQL server for cost reasons. If Keystone and Command Center (or eventually Waypoint) end up sharing one Azure SQL server to save cost, each project's connection string should still use a login scoped to only its own database — not a shared admin login reused across projects. Sharing a server is a cost optimization; sharing credentials across databases is a different, avoidable risk.

DocumentWhat it's for
keystone-signup-orchestration.mdThe real design this secrets list supports — where each of these keys actually gets used in a live request flow
waypoint-auth-review.mdWhy Waypoint's current secrets (GraphApi:ClientSecret, the shared Entra tenant) are a real, independent risk worth fixing regardless of the Keystone migration's timeline
platform-action-items.mdTracks the still-🔴 work items (deploying Keystone/Command Center, provisioning the shared Azure SQL server, Waypoint's actual migration) that this document's "not yet" rows depend on
keystone-production-secrets.mdKeystone's real Azure Key Vault/App Settings — this document's production counterpart for that one project

Document History​

DateChange
2026-09-26Initial version, written against real appsettings.Development.json files for Keystone, Command Center, and RoundTrip reviewed the same day
2026-09-28Added a pointer to the new keystone-production-secrets.md (Keystone's real Azure production secrets — out of scope for this local-dev-only document)