diff --git a/README.md b/README.md
index 682d698..af9f49b 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,35 @@ org.killbill.billing.plugin.stripe.chargeStatementDescriptor=ZZZ' \
http://127.0.0.1:8080/1.0/kb/tenants/uploadPluginConfig/killbill-stripe
```
+## Securing API Keys
+
+By default, API keys are stored in plaintext in Kill Bill's tenant configuration. You can keep secrets out of the database by using environment variable references.
+
+### Environment variable references
+
+Store your Stripe keys in environment variables and reference them in the plugin config:
+
+```
+org.killbill.billing.plugin.stripe.apiKey=${env:STRIPE_API_KEY}
+org.killbill.billing.plugin.stripe.publicKey=${env:STRIPE_PUBLIC_KEY}
+```
+
+This way, secrets never enter the database and can be managed by your orchestration layer (K8s Secrets, HashiCorp Vault, AWS SSM, etc.).
+
+For multi-tenant setups, each tenant can reference a different environment variable:
+
+```
+# Tenant A config
+org.killbill.billing.plugin.stripe.apiKey=${env:STRIPE_KEY_TENANT_A}
+
+# Tenant B config
+org.killbill.billing.plugin.stripe.apiKey=${env:STRIPE_KEY_TENANT_B}
+```
+
+### Migration note
+
+Existing plaintext configurations continue to work with zero changes. Environment variable references are fully opt-in.
+
## Payment Method flow
To charge a payment instrument (card, bank account, etc.), you first need to collect the payment instrument details in Stripe and create an associated payment method in Kill Bill.
diff --git a/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java b/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java
index 936104c..80dda1b 100644
--- a/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java
+++ b/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java
@@ -77,8 +77,8 @@ public class StripeConfigProperties {
public StripeConfigProperties(final Properties properties, final String region) {
this.region = region;
- this.apiKey = properties.getProperty(PROPERTY_PREFIX + "apiKey");
- this.publicKey = properties.getProperty(PROPERTY_PREFIX + "publicKey");
+ this.apiKey = StripeConfigPropertyResolver.resolve(properties.getProperty(PROPERTY_PREFIX + "apiKey"));
+ this.publicKey = StripeConfigPropertyResolver.resolve(properties.getProperty(PROPERTY_PREFIX + "publicKey"));
this.apiBase = properties.getProperty(PROPERTY_PREFIX + "apiBase");
this.proxyHost = properties.getProperty(PROPERTY_PREFIX + "proxyHost");
this.proxyPort = Integer.parseInt(properties.getProperty(PROPERTY_PREFIX + "proxyPort", "-1"));
diff --git a/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigPropertyResolver.java b/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigPropertyResolver.java
new file mode 100644
index 0000000..cd2a002
--- /dev/null
+++ b/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigPropertyResolver.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2020-2020 Equinix, Inc
+ * Copyright 2014-2020 The Billing Project, LLC
+ *
+ * The Billing Project licenses this file to you under the Apache License, version 2.0
+ * (the "License"); you may not use this file except in compliance with the
+ * License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+package org.killbill.billing.plugin.stripe;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.annotation.Nullable;
+
+/**
+ * Resolves config property values that may be plaintext or environment variable
+ * references ({@code ${env:VAR_NAME}}).
+ *
+ *
+ * - Plaintext — returned as-is (backward compatible)
+ * - {@code ${env:VAR_NAME}} — resolved via {@link System#getenv(String)}
+ *
+ */
+public class StripeConfigPropertyResolver {
+
+ private static final Pattern ENV_PATTERN = Pattern.compile("^\\$\\{env:([^}]+)}$");
+
+ /**
+ * Resolve a configuration value. Supports plaintext and {@code ${env:VAR}}.
+ *
+ * @param value the raw property value (may be {@code null})
+ * @return the resolved plaintext value, or {@code null} if input is {@code null}
+ */
+ public static @Nullable String resolve(@Nullable final String value) {
+ if (value == null) {
+ return null;
+ }
+
+ final Matcher envMatcher = ENV_PATTERN.matcher(value);
+ if (envMatcher.matches()) {
+ final String varName = envMatcher.group(1);
+ final String envValue = resolveEnvVar(varName);
+ if (envValue == null) {
+ throw new IllegalStateException("Environment variable '" + varName + "' is not set");
+ }
+ return envValue;
+ }
+
+ return value;
+ }
+
+ // Package-private for test overriding
+ static @Nullable String resolveEnvVar(final String name) {
+ return System.getenv(name);
+ }
+}
diff --git a/src/test/java/org/killbill/billing/plugin/stripe/TestStripeConfigPropertyResolver.java b/src/test/java/org/killbill/billing/plugin/stripe/TestStripeConfigPropertyResolver.java
new file mode 100644
index 0000000..d69971e
--- /dev/null
+++ b/src/test/java/org/killbill/billing/plugin/stripe/TestStripeConfigPropertyResolver.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2020-2020 Equinix, Inc
+ * Copyright 2014-2020 The Billing Project, LLC
+ *
+ * The Billing Project licenses this file to you under the Apache License, version 2.0
+ * (the "License"); you may not use this file except in compliance with the
+ * License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+package org.killbill.billing.plugin.stripe;
+
+import java.util.Properties;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+public class TestStripeConfigPropertyResolver {
+
+ @Test(groups = "fast")
+ public void testPlaintextPassthrough() {
+ Assert.assertEquals(StripeConfigPropertyResolver.resolve("sk_test_XXX"), "sk_test_XXX");
+ Assert.assertEquals(StripeConfigPropertyResolver.resolve("plain value"), "plain value");
+ Assert.assertEquals(StripeConfigPropertyResolver.resolve(""), "");
+ }
+
+ @Test(groups = "fast")
+ public void testNullPassthrough() {
+ Assert.assertNull(StripeConfigPropertyResolver.resolve(null));
+ }
+
+ @Test(groups = "fast")
+ public void testEnvVarResolution() {
+ // HOME is always set on Unix/macOS; use PATH as fallback
+ final String varName = System.getenv("HOME") != null ? "HOME" : "PATH";
+ final String expected = System.getenv(varName);
+ Assert.assertNotNull(expected, varName + " should be set in the test environment");
+ Assert.assertEquals(StripeConfigPropertyResolver.resolve("${env:" + varName + "}"), expected);
+ }
+
+ @Test(groups = "fast", expectedExceptions = IllegalStateException.class,
+ expectedExceptionsMessageRegExp = ".*Environment variable.*is not set.*")
+ public void testEnvVarNotSetFails() {
+ // Use a var name that is extremely unlikely to exist
+ StripeConfigPropertyResolver.resolve("${env:KILLBILL_STRIPE_TEST_NONEXISTENT_VAR_12345}");
+ }
+
+ @Test(groups = "fast")
+ public void testBackwardCompatibilityWithStripeConfigProperties() {
+ final Properties properties = new Properties();
+ properties.setProperty("org.killbill.billing.plugin.stripe.apiKey", "sk_test_plaintext");
+ properties.setProperty("org.killbill.billing.plugin.stripe.publicKey", "pk_test_plaintext");
+
+ final StripeConfigProperties config = new StripeConfigProperties(properties, "");
+
+ Assert.assertEquals(config.getApiKey(), "sk_test_plaintext");
+ Assert.assertEquals(config.getPublicKey(), "pk_test_plaintext");
+ }
+}