SfSkills Domains Docs GitHub

Home / apex / apex-secrets-and-protected-cmdt

apex-secrets-and-protected-cmdt

Storing API keys, signing secrets, and third-party tokens that Apex must consume — Protected Custom Metadata in a managed package, Protected Custom Settings, Encrypted Custom Fields, Apex Crypto, and what to NEVER do (hardcode, unprotected CMDT, System.debug). NOT for callout authentication — use apex/apex-named-credentials-patterns. NOT for computing the HMAC or signature itself — use apex/apex-encoding-and-crypto. NOT for record-level Shield data encryption — use security/platform-encryption.

Open the skill folder on GitHub


Apex Secrets and Protected Custom Metadata

Activate when Apex needs to consume a secret value — API key, HMAC signing key, third-party token, per-tenant credential — and the engineer is reaching for String API_KEY = '...'; or a plain Custom Setting. The platform offers a small set of correct mechanisms; pick the one that matches the secret's purpose and the deployment shape (managed package vs unmanaged DX), then document a rotation procedure.

Before Starting

Core Concepts

The canonical decision tree

Secret purposeCorrect storage
Callout authentication (Authorization header, OAuth, mTLS)Named Credential / External Credential — always. Apex never sees the credential.
HMAC signing / webhook verification keyProtected Custom Metadata in a managed package, retrieved via @NamespaceAccessible Apex
Symmetric encryption key (AES)Crypto.generateAesKey(256) at install time, store ciphertext via Protected CMDT or off-platform vault — never hardcode
Per-tenant lookup token / config secretProtected Custom Setting (Hierarchy) in a managed package
Field-level data encryption at restShield Platform Encryption (Encrypted Custom Fields)
Anything that needs admin-invisibility in the source orgOff-platform vault (HashiCorp, AWS KMS) — Salesforce cannot hide a value from a System Admin in the org that owns the metadata

Why "Protected" only protects against subscribers

Protected Custom Metadata Types and Protected Custom Settings expose values only to Apex code in the same managed-package namespace. In the subscriber org, the admin cannot read them via UI, SOQL, Workbench, the Tooling API, or anonymous Apex. In the packaging org (or any unmanaged project) the System Admin retains full visibility — protection is a packaging boundary, not a cryptographic one.

Implication: shipping "Protected" CMDT in an unmanaged 2GP or DX project gives you none of the protection you may have read about. The values land in force-app/main/default/customMetadata/MySecret.Foo.md-meta.xml and ride your git history forever.

@NamespaceAccessible and the secret-getter pattern

Inside the managed package, the secret retrieval method is annotated:

global class SecretsProvider {
    @NamespaceAccessible
    public static String getSecret(String key) {
        Secret_Config__mdt cfg = Secret_Config__mdt.getInstance(key);
        return cfg?.Value__c;
    }
}

The @NamespaceAccessible annotation lets other classes in the same namespace call getSecret; subscriber code cannot. Combined with Protected CMDT, this is the platform-canonical pattern for shipping a signing key.

Custom Settings, Hierarchy, Protected

A Hierarchy Custom Setting marked Protected behaves like Protected CMDT: only managed-package code can read it. Hierarchy is preferable to List for secrets because per-org values don't bloat the schema. Same caveat: only protected against subscribers.

Shield Platform Encryption is for record data, not configuration

Encrypted Custom Fields exist for PII and regulated record data — encryption-at-rest with key management (BYOK or platform-managed). They are not the right home for a webhook signing key; the value still appears in screens, reports (with permission), and Apex SOQL results. Use Shield for "the customer's tax ID," not for "our outbound HMAC secret."

Common Patterns

Pattern: HMAC signing key in Protected CMDT

Define Webhook_Secret__mdt with Value__c (Long Text Area), mark the type Protected, ship inside a managed package. Apex:

@NamespaceAccessible
public static Blob signingKey() {
    return Blob.valueOf(Webhook_Secret__mdt.getInstance('Outbound').Value__c);
}

Pattern: Custom-supplied per-tenant API key

Subscriber needs to plug in their own third-party key. Store it in a Protected Hierarchy Custom Setting with one record per packaging-namespace install, with a setup screen the admin uses to enter the value. Apex reads via Tenant_Config__c.getInstance().Api_Key__c from inside the namespace. The admin enters but cannot read back from outside the package.

Pattern: Symmetric crypto with rotated key material

Store key version metadata in Protected CMDT (Key_Version__c, Active__c, RetiredOn__c); store the actual ciphertext key in an off-platform vault retrieved via Named Credential at use time. Rotate by inserting a new active version and deprecating the prior one — old ciphertext still decrypts via version lookup.

Decision Guidance

SituationCorrect mechanismWhy
Apex needs to call a SaaS REST API with bearer tokenNamed Credential + External CredentialApex never sees the secret; framework injects it
Verifying inbound webhook signaturesProtected CMDT in managed packageSymmetric secret, retrieved by namespace-only Apex
Encrypting a value for storage in a custom fieldShield Platform Encryption (BYOK preferred)Platform handles key lifecycle and access control
Per-tenant integration credentialProtected Hierarchy Custom Setting (managed)Per-org configurable; admin writes but cannot read
Secret you must hide from your own org's adminOff-platform vaultSalesforce cannot hide metadata from a source-org admin
  1. Classify the secret by purpose using the decision tree; reject hardcoding outright.
  2. Confirm packaging shape — managed (namespace) vs unmanaged (DX). If unmanaged and the secret needs subscriber-invisibility, escalate to managed packaging or off-platform vault.
  3. Pick storage: Named Credential (callouts), Protected CMDT or Protected Custom Setting (managed package), Encrypted Field (record data), or off-platform vault (admin-invisible).
  4. Implement Apex retrieval with @NamespaceAccessible for managed-package secret getters; never read into a static final String constant.
  5. Add a .forceignore rule (or explicit absence from package.xml) so customMetadata/Secret_Config.*.md-meta.xml records do not get committed with values.
  6. Document a rotation procedure: who rotates, on what cadence, the runbook to update the CMDT row, and how dependent integrations re-sync.
  7. Run scripts/check_apex_secrets_and_protected_cmdt.py against the repo to detect hardcoded literals, unprotected secret-named CMDT fields, and System.debug of secret-named variables.

Review Checklist

Salesforce-Specific Gotchas

  1. Protected does not protect against the source-org admin. The promise applies only to subscribers of a managed package. In your own org, every System Admin can query, view, and export every "Protected" CMDT row.
  2. Custom Metadata records are source-controlled by default. sf project retrieve pulls customMetadata/*.md-meta.xml into git — including secret values someone typed in once. Add a .forceignore rule the day you create the type.
  3. @NamespaceAccessible is required to expose secret-getters across classes inside a managed package while keeping subscriber code locked out. Without it, either the method is private (unreachable from sibling classes) or global (callable by subscriber code).
  4. Rotation must be planned upfront. CMDT updates are deployable, but a rotation runbook that requires a deploy each time is operationally fragile — design the type so a manual record edit (by a controlled admin) is the rotation step.

Output Artifacts

ArtifactDescription
Secret-storage decision recordOne-pager mapping each secret to mechanism, owner, rotation cadence
Protected CMDT definitionXML for Secret_Config__mdt with Value__c Long Text Area
@NamespaceAccessible getterApex helper class shipped inside the managed namespace
.forceignore snippetExcludes customMetadata/Secret_Config.** from retrieve/deploy
Rotation runbookStep-by-step doc for the on-call rotation owner

Package files