Beyond Org Config & Secrets: Are You Doing It Right?

TL;DR

If you just want to know where to put your config and secrets, skip to the Decision Guide. If you want the "new knowledge" most teams don’t know about, jump to The Managed Package Pattern.

Intro

Be honest, have you ever stored an API key in a Custom Label? Or seen a hard-coded client secret in an Apex class that leaks every time someone checks the debug logs? Obviously bad. But let’s say you’re past those mistakes. You use Custom Metadata Types or Custom Settings like the docs recommend. Problem solved?

Not quite. CMDT copies your production secrets, unmasked, to every sandbox on refresh. Any admin can query them with SOQL. Custom Settings? They vanish entirely after a Developer sandbox refresh. And keeping the right values in sync across 5 sandboxes, 3 scratch orgs, and production? That usually lives in a spreadsheet only Dave understands.

Insecure storage of sensitive data is the #4 most common reason for AppExchange security review failure (Salesforce Developer Blog, 2023). And the Salesforce Code Analyzer even has a dedicated PMD rule for it: AvoidHardcodedCredentialsInApex. But the problem goes far beyond hard-coded credentials.

The platform gives you a dozen places to store configuration, but no clear guidance on which one to use when, especially for secrets. And even if you pick the right storage, managing those values across environments is a whole different challenge.

This post will walk you through all the options, their trade-offs, and a decision framework you can actually use. And yes, we’ll also talk about a pattern that might surprise you.

Let’s dive!

The Two Sides of the Problem

Before we get into specific tools, let’s frame the challenge correctly. When people talk about "config management" in Salesforce, they’re usually mixing up two separate problems:

1. HOW to Store It

What mechanism holds the value? Is it encrypted? Who can read it? Does it survive a sandbox refresh without leaking production secrets?

2. HOW to Manage It

How do you keep values in sync across environments? How do you prevent secret leakage after a refresh? How do you automate it at scale?

Most conversations focus on only one of these. But you need to solve both. The best storage mechanism in the world is useless if you can’t manage it across multiple environments without manual steps. And the best CI/CD pipeline can’t save you if your secrets are sitting in a Custom Label.

We’ll tackle storing first, then managing, and then a pattern that addresses both.

Storage Options

Custom Metadata Types — Config Yes, Secrets No

Custom Metadata Types (CMDT) are the go-to for non-sensitive configuration. Records are metadata, so they deploy with change sets, SF CLI, and packages. Access is cached (getAll() / getInstance(), no SOQL limits), they’re visible in test classes without SeeAllData=true, and they support metadata relationship fields. Salesforce recommends CMDT over List Custom Settings (which are effectively deprecated).

So what’s the problem? Have you ever used CMDT to store an API key or a client secret? If so, you’re not alone.

The Security Problem

CMDT has zero secret protection:

  • Any admin can see values in the Setup UI
  • SELECT Secret__c FROM MyConfig__mdt works in system-mode Apex with no FLS enforcement
  • Fully retrievable via Metadata API by anyone with "Customize Application" permission
  • Marking CMDT as "protected" in a standalone (non-managed-package) org does absolutely nothing

The Sandbox Refresh Trap

This is the one that catches most teams off guard. Because CMDT records are metadata, they copy to ALL sandbox types during refresh: Developer, Developer Pro, Partial, and Full. Every single one. Your production API keys, client secrets, and endpoint URLs are sitting right there, unmasked. Any admin, any developer with Metadata API access can see them.

There is no built-in masking mechanism for CMDT. That’s an architectural limitation.

Scratch orgs start empty. CMDT records must be deployed explicitly (sf project deploy), but since they’re metadata this is straightforward and can be part of your scratch org setup script.

Imagine this: your team refreshes the UAT sandbox on Friday evening. By Monday morning, a tester performs some tests in UAT, that triggers other opertaions, and it makes 47 real API calls to production systems because the CMDT endpoint values were still pointing at the production service. Nobody noticed until the external provider asked about the spike in traffic.

When CMDT IS the Right Answer

Mapping tables, business rule config, feature flags (IsEnabled__c), API endpoint base URLs (non-secret, but here still think about Named Credentials), approval thresholds, dynamic Apex class names, picklist value drivers. Basically, anything that isn’t a secret and should deploy with your code.

Custom Settings — Flexible, Same Security Story

Salesforce has two types of Custom Settings:

Hierarchical Custom Settings are unique on the platform. They’re the only mechanism with a three-level override: Org-wide → Profile → User (most specific wins). They support $Setup in formulas, validation rules, and flows, making them powerful for runtime configuration.

List Custom Settings provide key-value data sets accessible via getAll() and getInstance(). They’re effectively deprecated in favor of CMDT (from Salesforce point of view), but still widely used, and often for good reasons.

Both types are runtime editable via Apex DML, use cache-based access (zero SOQL), and require no deployment for value changes. But they share the same limitations: records are DATA, not metadata. There’s no field-level security (anyone with "View All Data" sees everything), and "Protected" visibility only works inside managed packages.

Sandbox Refresh Behavior — It’s Complicated

The behavior depends on the sandbox type, and this is where Custom Settings differ fundamentally from CMDT:

Sandbox Type What Happens
Developer / Dev Pro Only schema copies → records are GONE. You must recreate them.
Full Copy Everything copies, including production secrets (unmasked).
Partial Copy Depends on your template configuration. Known Salesforce issue (W-2937731): records sometimes don’t copy even with templates.
Scratch Org Starts empty. Records must be created via DML (data seeding script, anonymous Apex, or CI/CD post-create step).

Different headaches than CMDT, but headaches nonetheless.

Named Credentials + External Credentials — The Gold Standard for Callouts

If you’re storing authentication credentials for external HTTP callouts, this is the answer. Period.

Since Winter ’23 (API v56.0), Salesforce introduced a split architecture:

  • Named Credential = endpoint URL + transport protocol
  • External Credential = authentication protocol + secrets + principals

This separation is powerful: one OAuth External Credential can be reused across multiple Named Credentials (e.g., Google Drive API + Google Calendar API using the same OAuth client, provided the scopes cover both).

Why This Is THE Answer for Callout Secrets

  • Secrets are encrypted at rest with auto-generated org-specific keys
  • Secrets CANNOT be exported as plaintext via Metadata API
  • Secrets are NOT copied to sandboxes during refresh (by design!)
  • Automatic OAuth token refresh
  • Credentials never appear in code or debug logs
  • Access controlled via Permission Set → Principal assignment

Here’s what a callout looks like, with zero credentials in your code:

HttpRequest req = new HttpRequest();
req.setEndpoint('callout:My_Service/api/v2/data');
req.setMethod('GET');
HttpResponse res = new Http().send(req);

The Connect REST API — How CI/CD Works with Named Credentials

"But wait, if secrets can’t be deployed via Metadata API, how do you automate setup in CI/CD?"

The answer is the Connect REST API. This is the only way to programmatically update External Credential secrets. The ConnectApi.NamedCredentials Apex class provides the same capabilities as the Connect REST API (it’s the Apex equivalent). Whether you use the Apex class from within Salesforce or call the Connect REST API directly from an external system, you get the same operations and results.

In a CI/CD context, your pipeline will make direct Connect REST API calls (e.g., via curl or sf CLI commands in a GitHub Actions step) rather than going through Apex.

Limitations — Let’s Be Honest

  • HTTP callouts only. Named Credentials are not a general-purpose secrets vault. If you need to store an encryption key used in Apex logic, Named Credentials can’t help.
  • Setup is more complex than legacy: External Auth Identity Provider → External Credential → Principal → Named Credential → Permission Set mapping.
  • After sandbox refresh, re-authentication is required (tokens are intentionally not copied).
  • Scratch orgs require full setup from zero: External Credential + Principal + Named Credential structure via metadata deploy, then secret values via Connect REST API. The most complex scratch org setup of all options, but can be mitigated by properly using Scratch Org Snapshots.

Management Approaches

Now that we’ve covered storage, let’s cross to the other side of the problem. How do you keep these values correct and in sync across environments?

The best storage mechanism in the world doesn’t matter if your post-refresh process is "ask Dave, he knows the values."

Manual Management = Pain

If your post-sandbox-refresh process looks like this, you’re not alone:

  1. Sandbox refreshed → production secrets are on sandbox (CMDT) or missing entirely (Custom Settings)
  2. Someone needs to manually update/create config values
  3. Need to remember ALL the settings, ALL the values, for EACH environment
  4. It’s undocumented (or documented in a Confluence page nobody updates)
  5. New team member joins → takes days to set up their sandbox
  6. Multiply by 5 sandboxes × 20 config values = 100 manual steps

The real risks?

  • Human error: wrong value in wrong environment → production callouts hitting sandbox APIs (or vice versa!)
  • Forgotten step: one setting not updated → integration silently fails
  • Knowledge silos: only one person knows "the list"
  • No audit trail: who changed what, when?
  • Time drain: hours per refresh that could be automated

If you’re doing this today, it doesn’t scale, and it will bite you.

CI/CD Approach — Better, But With Trade-offs

The CI/CD approach brings real advantages, and it applies to both configs and secrets:

  • Automated deployment pipeline handles config alongside code
  • Secrets stored in CI/CD secret store (GitHub Secrets, Vault, etc.), NOT in source control
  • Environment-specific configuration values (feature flags, batch sizes, routing rules) injected at deploy time via string replacements
  • Environment-specific secrets pushed via post-deploy scripts (Connect REST API for Named Credentials, DML for Custom Settings, or package REST API for managed packages)
  • Repeatable, auditable, no human memory required
  • In the AI era, your CI/CD pipeline IS the documented refresh process

SF CLI String Replacements

The key mechanism is sfdx-project.json string replacements:

{
  "replacements": [
    {
      "filename": "...namedCredentials/MyService.namedCredential-meta.xml",
      "stringToReplace": "https://api.example.com",
      "replaceWithEnv": "API_ENDPOINT_URL"
    }
  ]
}

Values come from environment variables → which come from GitHub Environment secrets → different per target org. Your source repo stays environment-agnostic.

Of course, other option is using other commands to replace strings, like old friend sed.

GitHub Actions Example

name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production # ← scoped secrets!
    steps:
      - uses: actions/checkout@v4
      - name: Authenticate
        run: echo "${{ secrets.SFDX_AUTH_URL }}" | sf org login sfdx-url -s -u
      - name: Deploy with string replacements
        env:
          API_ENDPOINT_URL: ${{ secrets.PROD_API_ENDPOINT }}
        run: sf project deploy start -d force-app/ -l RunLocalTests

But Here Come the Trade-offs

CMDT + Sandbox Refresh = Secret Leakage Window. When you refresh a sandbox, ALL CMDT records copy from production, including secrets. Your CI/CD pipeline can run a post-refresh job to overwrite them with sandbox-safe values. BUT there’s a time window between refresh completion and the post-step execution where production secrets sit on the sandbox. You can mitigate by restricting CMDT access to the CI/CD integration user and running SandboxPostCopy ASAP, but SandboxPostCopy for CMDT requires Metadata.Operations.enqueueDeployment() (not standard DML), which is asynchronous and subject to the 10-concurrent-deployments limit. This window can never be fully eliminated with CMDT.

Here’s a real-world scenario: refresh completes at 2am Saturday. SandboxPostCopy fails silently because of a metadata deploy conflict. Nobody notices until Wednesday. That’s 4 days of production secrets sitting on the sandbox.

Custom Settings + Sandbox Refresh = Records Gone. Custom Settings records are data. After a Developer sandbox refresh, they’re simply gone. Your CI/CD pipeline needs to recreate them, which is simpler (standard DML, can run in SandboxPostCopy) but means the org is non-functional until the job completes. From security standpoint – it is much better. But not always we can use Custom Settings.

Scratch Orgs = Extra Steps. Scratch orgs start from zero. The typical flow: create scratch org → deploy code + CMDT structure → run data seeding script for Custom Settings → set External Credential values via Connect REST API. Each step needs environment-specific secrets injected from CI/CD. This complexity is one of the key reasons many teams avoid scratch orgs entirely, because the environment config setup feels like too much overhead. Automating this in CI/CD (or via the managed package pattern discussed next) removes the barrier and makes scratch orgs practical for day-to-day development. Another option worth exploring is Scratch Org Snapshots: pre-configured org templates that can be cloned with config already in place, skipping the setup entirely. That’s a separate topic, but worth knowing about.

The Managed Package Pattern — The Only True Protection on the Platform

This is where things get genuinely interesting, and where most teams haven’t looked.

A managed package (1GP or 2GP) with protected Custom Settings and/or protected CMDT is the only mechanism on the Salesforce platform that makes stored values truly invisible to the subscriber org. Not restricted, not encrypted. Invisible. No Setup UI, no SOQL, no Metadata API, no Data Loader. There is simply no standard platform path to read the data from outside the package namespace.

If you haven’t worked with managed packages before: every managed package has a namespace prefix (e.g., myapp). This namespace acts as an isolation boundary. All Apex classes, custom objects, Custom Settings, and CMDT inside the package live in that namespace, and Salesforce enforces strict rules about what code outside the namespace can access. When we say "protected by the package namespace," we mean that only Apex code that belongs to the package itself can read or write those values. Code in the subscriber org (where the package is installed) simply cannot reach them.

Every other storage option we’ve discussed (CMDT, Custom Settings, even Shield-encrypted fields) can be accessed by someone with the right permissions. Protected components in a managed package cannot.

You don’t need to be an ISV to use this pattern. You don’t need to publish to AppExchange. You don’t need a partner agreement. A 2GP package can be created from any Dev Hub, installed in your own orgs, and never listed publicly. No cost, no approval process. Just a Dev Hub and sf package create. Think of it as an infrastructure package, like a utility library but for configs and secrets.

Why "Protected" Only Works in Managed Packages

Let’s be clear about what "protected" means and what it doesn’t:

  • In a standalone org (no managed package): marking Custom Settings or CMDT as "Protected" does absolutely nothing. It’s a no-op. Admins, SOQL, Metadata API, everything still works.
  • In a managed package namespace: "Protected" makes components genuinely invisible to the subscriber org. Not just restricted, but invisible. No Setup UI visibility, no SOQL access from subscriber code, no Metadata API retrieval.
  • In 2GP: PackageProtected goes further, restricting access to code within the same specific package, even across packages sharing a namespace.

This is the platform’s strongest access control mechanism for stored values. Stronger than Named Credentials for non-callout secrets, because there’s literally no standard platform path to read the data from outside the package.

A critical caveat: this only holds if the package is built properly. A poorly designed package (one that exposes too-permissive global methods, lacks input validation on its REST API, or doesn’t enforce proper authorization checks) can still leak values to unintended actors. The protection is only as strong as the package’s access layer design. Building a secure config vault package requires deliberate architecture, not just slapping "Protected" on a Custom Setting.

The Architecture — Four Layers

Think of the managed package pattern in layers:

1. Storage Layer. Protected Custom Settings or Protected CMDT inside the package namespace. This is where the actual secrets and configuration values live. Completely invisible to the subscriber org.

2. Access Layer. Global Apex methods that expose operations (not raw secrets) to subscriber code. This is a critical design decision, more on this below.

3. Management Layer. A REST API endpoint (@RestResource) for programmatic writes, and optionally an LWC configuration UI for authorized admins to enter/update values manually. Post-install scripts (InstallHandler) can set initial defaults.

4. Automation Layer. CI/CD pipelines calling the REST endpoint, or a Hub-to-Spoke communication pattern for multi-org environments. This is where the management problem gets solved.

Why This Works: The Managed Package as a Regulated Environment

The real security of this pattern isn’t just about visibility. It’s about control over what code runs inside the package namespace.

In a well-managed package, every Apex class is reviewed and approved by a technical council before it’s deployed. A developer can’t simply add System.debug(myProtectedSetting.Secret__c) to a package class and push it to production. It has to go through the review and release process. This governance layer is what makes the "invisible to subscriber org" guarantee meaningful in practice.

Accessing Protected Values: Two Approaches

There are two ways to expose protected values to subscriber code, each with different security trade-offs:

Approach 1: Expose operations, not secrets. A global method like encryptPayload(plaintext) that uses the encryption key internally but never returns it. The secret never leaves the package namespace. The subscriber code gets the result of the operation, not the key itself. This is the most secure approach. Even a developer with Anonymous Apex access can’t extract the raw secret.

Approach 2: Expose values via global Apex methods. A method like getLicenseKey() that returns the secret directly. This is less secure: any developer with "Author Apex" permission can call it via Anonymous Apex. But it’s also more flexible, and sometimes necessary when the subscriber code needs to use the secret in a way the package can’t anticipate. The protection here is that secrets are still invisible in Setup UI, SOQL, Metadata API, and Data Loader, which eliminates the most common and accidental exposure vectors. It’s a pragmatic trade-off that some teams accept.

For non-secret configuration values (like batch sizes, retry counts, or integration-specific thresholds), exposing them directly through global methods is perfectly fine. The key distinction is: for secrets, prefer operations inside the package when possible; for configuration, reading values from the package is expected.

Two Flavors: Protected CMDT vs Protected Custom Settings

This is where the choice gets architecturally interesting, because the two behave fundamentally differently during sandbox refresh.

Protected CMDT — The "Auto-Deploy" Pattern

Because CMDT records are metadata, they copy to ALL sandbox types during refresh (Developer, Partial, Full). This means:

  • After refresh, all configuration is immediately available on the sandbox. The org is functional from the moment refresh completes, no post-refresh action needed.
  • Production secrets are physically present on the sandbox, but they’re invisible to subscriber admins. No Setup UI, no SOQL, no Metadata API can reach them.
  • The package’s global methods are the only access path. If the package follows the "expose operations, not secrets" principle, production secrets never surface to subscriber code either.
  • For per-org scoping, the package can filter by Environment Name (sandbox name) or Organization.Id, returning only values relevant to the current org. This means even though ALL configs for ALL environments are present on every sandbox after refresh, the org automatically uses its own configs. The trade-off is clear: configs for all your other environments are also physically present on that org, but invisible to anyone outside the package namespace.
  • Updates from within the package use Metadata.Operations.enqueueDeployment(), which is asynchronous.

Pros:

  • Zero post-refresh downtime. Config is available immediately.
  • No CI/CD step needed after sandbox refresh for config availability.
  • Values deploy automatically with the metadata. Predictable behavior.

Cons:

  • Production secrets physically exist on every sandbox, even if hidden behind namespace protection. For highly regulated environments, "hidden but present" may not satisfy compliance requirements.
  • If a vulnerability is ever found in the package’s global methods, production secrets on sandboxes could be exposed until a package upgrade is deployed.

Protected Custom Settings — The "Clean Slate" Pattern

Because Custom Settings records are data, their sandbox behavior is fundamentally different:

  • Developer / Dev Pro sandboxes: records are gone after refresh. Production secrets literally don’t exist on the sandbox. This is the strongest security posture available. You can’t leak what isn’t there.
  • Full Copy sandboxes: records DO copy, but they’re still protected by the package namespace, invisible to SOQL, Metadata API, and Setup UI. The same rules apply here as with Protected CMDT (production values are present but hidden). However, since Custom Settings support standard DML, the post-refresh process can first delete all copied configs and then recreate only the relevant ones for that environment. This gives you a clean reset that’s not possible with CMDT.
  • Updates use standard Apex DML from within the namespace. Synchronous, simple, no deployment limits.

The trade-off: the org is non-functional after a Dev sandbox refresh until someone populates or recreates the config. This requires an active push mechanism: CI/CD, manual entry via the package’s LWC UI, or the Hub-to-Spoke pattern (see below).

Pros:

  • Dev sandboxes are truly clean, no production secrets present at all.
  • Synchronous DML-based writes: simple, immediate, no deployment queue.
  • SandboxPostCopy integration is straightforward (standard DML works).
  • Stronger compliance story: "production secrets do not exist on development sandboxes."

Cons:

  • Org is non-functional until config is populated.
  • Requires active push mechanism for every sandbox refresh.
  • Full Copy sandboxes still copy production values (protected, but present).

Choosing Between Them

Factor Protected CMDT Protected Custom Settings
After Dev sandbox refresh Config available immediately Config MISSING, needs re-push
Prod secrets on Dev sandbox Present (hidden) Absent
Update mechanism Async metadata operations Sync DML
Compliance requirements "Hidden but present" "Not present"
Post-install defaults Metadata deploy (complex) Simple DML
Best for Config that should "just work" after refresh Secrets where "not present" matters

Many implementations use both: Protected CMDT for non-sensitive environment config (feature flags, batch sizes) that should auto-deploy, and Protected Custom Settings for actual secrets (encryption keys, signing certificates, license keys) that should never exist on lower environments.

Managing the Package — Three Approaches

The managed package solves the storage problem elegantly for both configs and secrets. But how do you get the right values INTO those protected settings across environments? There are three approaches, each suited to different scales.

Approach 1: Manual (LWC UI)

The package exposes an admin-facing LWC configuration page. Authorized users enter values manually after install or refresh.

  • Works for: small teams, 1-3 environments, low refresh frequency
  • Limitation: still manual. Doesn’t scale. Dependent on someone remembering to do it. But it IS better than unprotected Custom Settings because the values are at least hidden from casual browsing.

Approach 2: CI/CD Direct Push

This is the same CI/CD approach we discussed earlier: the same GitHub Actions pipelines, the same environment secrets, the same automation principles. The only difference is the target: instead of writing to unprotected CMDT or Custom Settings (where admins can see everything), the pipeline calls the managed package’s REST API to write directly into protected storage. Same workflow, dramatically better security posture.

This requires a Connected App with appropriate OAuth scopes on each target org, and the CI/CD user needs explicit access to the package’s REST endpoint.

  • Works for: teams with existing CI/CD pipelines, 3+ environments
  • Limitation: each environment needs a post-refresh CI/CD job triggered somehow (webhook, scheduled check, or manual trigger).

Approach 3: Hub-to-Spoke Push

This is the most sophisticated approach, and the one that truly automates multi-org management. A central Hub org (typically production or a dedicated config hub) pushes configuration to all connected spoke orgs (sandboxes, scratch orgs) via the package’s REST API.

How it works:

  • The Hub org stores the master configuration for all target environments. It authenticates to each spoke org via JWT Bearer flow using a Connected App deployed as part of the package.

  • Each spoke org has the package installed and exposes the @RestResource endpoint for receiving config pushes.

  • The Hub calls each spoke’s REST API to push the environment-appropriate configuration. Orchestration logic on the Hub determines which config goes where, triggered manually, on a schedule, or event-driven.

  • Works for: large enterprises with 10+ environments, ISVs managing subscriber orgs, multi-production-org estates

  • Limitation: significant setup complexity. The Hub needs Named Credentials or stored auth tokens for each spoke. Orchestration logic needs to be built or configured. It can take time for configs to propagate across many orgs.

Scratch Org Adoption — The Hidden Benefit

The managed package pattern also removes one of the biggest barriers to scratch org adoption: environment config setup. Instead of writing custom seeding scripts for every config value, your CI/CD post-create step simply calls the package’s REST API to push default dev configuration. With Hub-to-Spoke, the Hub can automatically push config to newly created scratch orgs. Config can also be included as part of the package’s post-install defaults via InstallHandler. The result: sf org create scratch → package install → config auto-populated. Scratch orgs become practical instead of painful.

Management Approach Comparison

Aspect Manual (LWC UI) CI/CD Direct Push Hub-to-Spoke Push
Post-refresh automation Manual entry Automated (pipeline job) Automated (Hub triggers)
Scratch org support Manual entry Automated (pipeline job) Possible but overkill
Requires CI/CD No Yes Hub needs orchestration
Scales to # of orgs 1-3 3+ 3+
Setup complexity Low Medium/High High
Single source of truth No (each org independent) CI/CD secret store Hub org

When the Managed Package Pattern Is Worth It

Justified when:

  • You have 5+ environments that need the same config structure with different values
  • You handle secrets that must NOT exist on development sandboxes (use the Protected Custom Settings variant)
  • You’re an ISV and already have a managed package, so adding protected settings is incremental effort
  • Compliance requirements mandate that secrets are not accessible via standard platform APIs (SOQL, Metadata API, Setup UI)
  • You want to centrally manage and push configuration across many environments

Overkill when:

  • You have 1-2 sandboxes and a mature CI/CD pipeline
  • Your secrets are exclusively for HTTP callouts (Named Credentials already solves this)
  • You don’t have the team capacity to maintain a managed package lifecycle (versioning, upgrades, testing across orgs)

A minimal 2GP package for this purpose is surprisingly lightweight: one protected Custom Setting, one @RestResource class, one global Apex accessor. The packaging overhead is a one-time cost; the security benefit is permanent. But if you’d rather not build and maintain your own, there are ready-made solutions on AppExchange (see the end of this post).

A Note on Package Upgrades

When you upgrade the managed package, existing protected settings values are preserved. Only new fields or records from the package get their defaults. This means subscriber configurations survive upgrades cleanly.

However, if you discover a vulnerability in your global methods, the remediation path is a package upgrade to all orgs where it’s installed. For 2GP, this means subscribers need to install the new version. Plan your versioning and communication strategy accordingly.

The Big NOs

Before we get to the decision guide, let’s be crystal clear about what you should never do. If you take one thing from this post, it’s this list.

Security NOs

  • Never hard-code secrets in Apex, LWC, Aura, or Visualforce. Leaks via debug logs, error messages, Metadata API. Flagged by SF Code Analyzer.
  • Never store secrets in Custom Labels. Zero security, no FLS, deploys identically everywhere.
  • Never store secrets in plain custom object records. Accessible via API, Data Loader, reports. No encryption.
  • Never store secrets in Static Resources. Metadata, visible in Setup, potentially accessible via Sites.
  • Never commit secrets to source control. .env, .key, .pem files in Git are exposed to anyone with repo access. You can scrub them from history with tools like git filter-repo or GitHub’s secret scanning, but it requires a force push, doesn’t reach clones others already have, and the secret should be considered compromised regardless. Prevention is far easier than cleanup.
  • Never use the same credentials across all environments. Production API keys on sandbox = accidental production calls from test code.
  • Never rely on "security through obscurity". Obfuscated Apex, base64-encoded values, or "nobody will look there" is not security.

Management NOs

  • Never run sandbox refresh without a documented post-refresh process. If the steps live only in someone’s head, you’re one resignation away from chaos.
  • Never skip automating post-refresh configuration. Manual checklists get skipped, forgotten, or done inconsistently.
  • Never let production credentials linger on sandboxes, even "just for a few hours." That window is when accidents happen.
  • Never manage config without a single source of truth. If five people have five different spreadsheets with "the correct values," nobody has the correct values.

Decision Guide — Where to Store What

Start here. Follow the tree to find the right storage mechanism for your use case:

Quick reference table:

What are you storing? Use this
Authentication credentials for external HTTP callouts Named Credentials + External Credentials. Always, period.
Secrets that must not appear in Setup UI, SOQL, or Metadata API Protected Custom Settings/CMDT in a Managed Package
Non-sensitive config that should deploy with code Custom Metadata Types
Config that varies by user or profile at runtime Custom Settings (Hierarchical). The only option with org → profile → user hierarchy
Feature toggles Custom Permissions (per-user, admin-managed via Permission Sets)
Translatable UI text Custom Labels. And ONLY Custom Labels

Decision Guide — How to Manage It

Follow the tree based on where you are today and what you need to protect:

Remember, this applies to both configuration values AND secrets. The right approach depends on your scale, security requirements, and what you’re managing:

Approach Scalable Secure Audit Trail Post-Refresh Scratch Org
Manual No No No Slow, error-prone Painful
CI/CD + CMDT Yes Refresh leakage window + Visible to admins Yes Small leak window String replacements
CI/CD + Custom Settings Yes Visible to admins Yes Needs seeding Seeding script
CI/CD + Named Credentials Yes Encrypted Yes Connect REST API ConnectApi setup
Managed Package + CI/CD Yes Hidden from admins Yes Protected by package API-based setup
Managed Package + Hub Push Yes Hidden + centralized Yes Auto-push from Hub Auto-push from Hub

The sweet spot for most teams: Named Credentials for callout authentication + Custom Settings/CMDT for non-sensitive configuration + CI/CD automation. If you need secrets that are truly invisible to org admins and never leak to sandboxes, the managed package pattern is the next level.

Master Comparison — All Storage Mechanisms at a Glance

Mechanism Encrypted? Sandbox Refresh Deployable as Metadata? Runtime Editable? SOQL Accessible? Metadata API Accessible? Setup UI Visible?
Custom Metadata Types No Copies to ALL types (unmasked) Yes Via metadata operations Yes (system-mode) Yes Yes
Custom Settings No Dev: gone / Full: copies No (data) Yes (DML) Yes (cache methods) No (data) Yes
Named Credentials Yes (at rest) Secrets NOT copied Partial (no secrets) Via Connect REST API No Yes (but not Principal Secrets) Partially
Protected CMDT (Managed Pkg) No Copies to ALL types (hidden) Within namespace or package (2GP) Via metadata ops (async) No (outside scope) No No
Protected Custom Settings (Managed Pkg) No Dev: gone / Full: copies (hidden) Within namespace only Via DML (sync) No (outside scope) No No
Custom Labels No Copies to ALL types Yes No (deploy only) No Yes Yes
Custom Permissions N/A Copies with metadata Yes No (Permission Set assignment) No Yes Yes

What to Do Next

Configuration and secrets management in Salesforce doesn’t have a single "right answer". It has a right answer for each type of data. Here’s how to get there:

Step 1: Audit What You Have

Go through your orgs and find every place where secrets and config values are stored today. Custom Labels with API keys? CMDT records with client secrets? Hard-coded values in Apex? Custom Settings visible to every admin? Make a list. You can’t fix what you don’t know about.

Step 2: Classify and Relocate

For each item on your list, use the Decision Guide to determine the right storage mechanism:

  • Callout credentials → migrate to Named Credentials + External Credentials
  • Non-sensitive config → keep in CMDT or Custom Settings (whichever fits your use case)
  • Secrets that must be invisible to admins → evaluate the managed package pattern

Step 3: Automate the Management

Document your post-refresh process. Then automate it. Set up CI/CD pipelines that handle config deployment alongside code. Make sure every sandbox refresh and scratch org creation is self-sufficient, with no dependency on someone remembering "the list."

Step 4: Validate

Run a sandbox refresh and verify: are production secrets still exposed? Are all config values correct for the target environment? Is the process repeatable without manual intervention? If yes, you’re in good shape. If not, iterate.

If you have questions or want to discuss your specific architecture, reach out. This is one of those topics where the right answer depends heavily on your context.


Don’t want to build and maintain your own managed package for the protected settings pattern? At Beyond The Cloud, we built Veles exactly for this. Install the package, populate your secrets and configs once per environment through the UI or REST API, and you’re done. Sandbox refreshes, scratch org provisioning, and Hub-to-Spoke push are handled out of the box. One-time setup, ongoing protection. Check it out.

Resources

Mateusz Babiaczyk
Mateusz Babiaczyk
Salesforce Certified Technical Architect
Certified Technical Architect and skilled Full-stack Salesforce Developer since 2019. Achiever mindset, always ready to learn new technologies, and committed to ongoing self-improvement.