Move credentials out of services
Applications, CI jobs, and local tooling can work with placeholders instead of bearer tokens, API keys, and passwords.
SecretProxy is an outbound credential-injection proxy. Applications send {{PLACEHOLDER}} tokens instead of real secrets, and the proxy injects the credential only at egress. That changes the runtime exposure story for code, CI, memory, logs, and incident response.
secretproxy.io/{target}/{path}
Traditional secret managers solve the storage problem: secrets are encrypted at rest, access is audited, and rotation is possible. But many workloads still have to fetch the secret into application memory before they can use it. That means the credential may still appear in environment variables, crash dumps, debugging sessions, shell histories, or custom app-level caching logic.
SecretProxy solves the last-mile delivery problem. The real credential does not need to enter the application boundary. Instead, the service sends a placeholder and the proxy resolves it against the bound target and secret version at request time.
Applications, CI jobs, and local tooling can work with placeholders instead of bearer tokens, API keys, and passwords.
Bindings are tied to a target host, a placeholder, and a specific secret version, so replaying a token against the wrong host fails.
Secret versions are immutable and bindings stay explicit, enabling staged promotion and rollback instead of hidden app reload logic.
SecretProxy runs as a four-worker platform: the public proxy worker on the request hot path, the website worker for product and docs content, the API worker for CRUD management, and the UI worker for the dashboard. All of them share one D1-backed control plane.
Your application sends a request to secretproxy.io/api.stripe.com/v1/charges with a placeholder header. SecretProxy resolves the binding, enforces the injection policy, inserts the real credential, and forwards the request to api.stripe.com. The response comes back transparently.
A concise view of how to position, evaluate, and roll out SecretProxy for security leadership, platform engineering, and regulated production workloads.
The enterprise case for SecretProxy is straightforward: reduce how often high-value third-party credentials live inside the software supply chain. If an application instance, CI runner, or debug environment is compromised, the attacker should not automatically gain every outbound API token used by that workload.
SecretProxy changes whether secrets need to be present inside service memory at all. That is the difference between storage hygiene and runtime isolation.
Targets, bindings, versions, and IP allowlists live in one control plane instead of being reimplemented separately in every service.
Teams usually update the request URL and replace live secrets with placeholders. They do not have to adopt a sidecar or a new SDK per language.
The hosted control plane is the fastest path for central platform teams. For stricter environments, the docs and roadmap already point to an in-cluster enterprise proxy model where services call an internal hostname and the final secret injection happens inside the cluster boundary.
Positioning: SecretProxy does not replace a secret manager's storage controls. It complements them by removing the need for many applications to ever fetch the real value into memory in the first place.
Start with a few high-value third-party integrations where credentials are widely copied today.
Move ownership of target registration, rotation rules, and reviewability into the platform or security engineering lane.
Adopt the private or in-cluster deployment shape for regulated workloads without changing the placeholder model developers already use.
Jump to the Kubernetes guide when you want to evaluate the in-cluster enterprise path in more concrete detail.
Go from zero to your first proxied request in under five minutes.
Sign up at app.secretproxy.io and log in to the dashboard.
Add your first target. A target is the upstream API hostname you want to call (e.g., httpbin.org). Go to Targets → Add Target.
Create a secret. Go to Secrets → Add Secret and enter a name (e.g., HTTPBIN_TOKEN) and the actual credential value.
Create a binding. Go to Bindings → Add Binding. Select the target, choose the secret version, set the placeholder to HTTPBIN_TOKEN, and choose header injection with header name Authorization and template Bearer {{value}}.
Test with curl:
curl https://secretproxy.io/httpbin.org/get \
-H "Authorization: {{HTTPBIN_TOKEN}}"SecretProxy replaces {{HTTPBIN_TOKEN}} with Bearer <your-secret-value> and forwards the request to httpbin.org/get. The response shows the resolved Authorization header.
Update your application code. Replace the upstream URL with the SecretProxy URL and swap the real credential for a placeholder. That is the only code change needed.
// Before
const res = await fetch('https://api.acme.com/v1/data', {
headers: { 'Authorization': 'Bearer sk_live_abc123' }
});
// After (one URL change, remove the secret)
const res = await fetch('https://secretproxy.io/api.acme.com/v1/data', {
headers: { 'Authorization': '{{ACME_KEY}}' }
});Use SecretProxy in CI/CD pipelines to keep credentials out of workflow logs.
GitHub Actions secrets are injected as environment variables, which are visible to every step in the job. Any dependency, build script, or post-processing step can read them. If a step logs the environment or a crash dump is collected, the secrets are exposed.
Route your API calls through SecretProxy. The workflow never sees the credential — it only sends placeholders. The real secret is injected at the proxy edge.
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Call Payment API
run: |
curl https://secretproxy.io/api.acme.com/v1/deploy \
-H "Authorization: {{DEPLOY_KEY}}" \
-H "Content-Type: application/json" \
-d '{"version": "abc123"}'GitHub Actions runners use a set of IP ranges that change over time. To whitelist them:
actions CIDR blocks to your SecretProxy IP whitelist via the dashboard.Note: GitHub Actions IPs rotate frequently. For more stable access, consider using a self-hosted runner with a fixed IP, or the SecretProxy CLI which authenticates with an API token instead of IP whitelisting.
Proxy traffic from K8S microservices without mounting secrets into pods.
Point your services directly at secretproxy.io. Whitelist your cluster's egress IP addresses in the SecretProxy dashboard.
# Pod using SecretProxy (SaaS)
apiVersion: v1
kind: Pod
metadata:
name: payment-service
spec:
containers:
- name: app
image: myapp:latest
env:
- name: PAYMENT_API_URL
value: "https://secretproxy.io/api.acme.com"For enterprises, deploy a SecretProxy egress proxy inside your cluster. Secrets are synced from the control plane and decrypted locally. Services call secretproxy.local instead of secretproxy.io, keeping all traffic internal.
# In-cluster egress proxy deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: secretproxy-egress
spec:
replicas: 2
template:
spec:
containers:
- name: proxy
image: secretproxy/egress:latest
ports:
- containerPort: 8080Find your cluster's NAT gateway or load balancer public IP and add it to the SecretProxy IP whitelist. For managed clusters (EKS, GKE, AKS), this is typically the NAT gateway IP of the VPC.
Remove credentials from Lambda environment variables and CloudWatch logs.
Lambda environment variables are visible in the AWS Console, CloudTrail, and CloudWatch logs. Any IAM principal with lambda:GetFunction can read them. Secrets Manager SDKs add cold-start latency and still bring the secret into Lambda memory.
Call through SecretProxy from your Lambda function. The secret never touches the Lambda runtime.
// Lambda function calling through SecretProxy
export const handler = async (event) => {
const response = await fetch(
'https://secretproxy.io/api.acme.com/v1/process',
{
method: 'POST',
headers: {
'Authorization': '{{PAYMENT_KEY}}',
'Content-Type': 'application/json',
},
body: JSON.stringify(event),
}
);
return response.json();
};If your Lambda runs inside a VPC, whitelist the NAT Gateway's Elastic IP. If it runs without a VPC, Lambda uses shared AWS IPs that are not stable — in that case, place the Lambda in a VPC with a NAT Gateway for a fixed egress IP.
Note on latency: SecretProxy runs on Cloudflare's edge network. The added latency is typically 5–20ms, which is less than the cold-start overhead of fetching from AWS Secrets Manager.
Integrate SecretProxy with traditional server deployments.
Whitelist the instance's Elastic IP (or NAT Gateway IP if behind a VPC) in the SecretProxy dashboard. Then route your HTTP calls through the proxy.
curl https://secretproxy.io/api.acme.com/v1/status \
-H "Authorization: {{ACME_KEY}}"import requests
resp = requests.get(
"https://secretproxy.io/api.acme.com/v1/status",
headers={"Authorization": "{{ACME_KEY}}"}
)
print(resp.json())const res = await fetch('https://secretproxy.io/api.acme.com/v1/status', {
headers: { 'Authorization': '{{ACME_KEY}}' }
});
const data = await res.json();For long-running daemons, no special configuration is needed. The proxy is a standard HTTPS endpoint — just point your application's API URL at SecretProxy.
You can configure nginx to route all outbound API traffic through SecretProxy:
location /api/ {
proxy_pass https://secretproxy.io/api.acme.com/;
proxy_set_header Host secretproxy.io;
proxy_set_header Authorization "{{ACME_KEY}}";
}Use SecretProxy from Docker containers without mounting secret files or environment variables.
# docker-compose.yml
services:
api:
image: myapp:latest
environment:
PAYMENT_URL: "https://secretproxy.io/api.acme.com"
# No secrets here! The app uses placeholders.To whitelist your container's IP, you need to know the egress IP that SecretProxy will see. Run this from inside the container:
curl -s https://httpbin.org/ipAdd the returned IP to your SecretProxy IP whitelist in the dashboard.
Tip: In Docker Desktop (macOS/Windows), containers share the host's public IP. In production (Linux host, bridge network), the egress IP is the host machine's public IP or NAT gateway.
Develop locally with real proxy behavior.
Go to the SecretProxy dashboard and add your current public IP to the whitelist. You can find your IP by visiting httpbin.org/ip.
The SecretProxy CLI provides a local proxy for development, so you can test placeholder resolution without deploying:
# Install the CLI
npm install -g @secretproxy/cli
# Login to your account
secretproxy login
# Start the local proxy (port 8787)
secretproxy dev
# Test a request through the local proxy
curl http://localhost:8787/httpbin.org/get \
-H "Authorization: {{HTTPBIN_TOKEN}}"The dashboard includes a Test page where you can construct a proxied request, inspect how placeholders are resolved, and see the upstream response — all from the browser.
// Use the proxy URL from environment
const PROXY = process.env.PROXY_URL || 'https://secretproxy.io';
const res = await fetch(`${PROXY}/api.acme.com/v1/data`, {
headers: { 'Authorization': '{{ACME_KEY}}' }
});Set PROXY_URL=http://localhost:8787 in development and PROXY_URL=https://secretproxy.io in production.
Manage SecretProxy resources as infrastructure as code.
terraform {
required_providers {
secretproxy = {
source = "ovrlab/secretproxy"
version = "~> 0.1"
}
}
}
provider "secretproxy" {
api_url = "https://api.secretproxy.io" # default
api_key = var.secretproxy_api_key
}resource "secretproxy_target" "payment_api" {
name = "Payment Gateway"
base_url = "api.acme.com"
description = "Production payment API"
}
resource "secretproxy_target" "email_service" {
name = "Email Service"
base_url = "api.email-provider.com"
description = "Transactional email API"
}Bindings connect a placeholder to a secret for a specific target. The secret must already exist in the dashboard.
resource "secretproxy_binding" "payment_auth" {
target_id = secretproxy_target.payment_api.id
secret_id = 1 # Secret created in dashboard
placeholder = "PAYMENT_KEY"
injection_type = "header"
header_name = "Authorization"
header_template = "Bearer {{value}}"
}# Whitelist a Kubernetes cluster's NAT gateway
resource "secretproxy_allowed_ip" "k8s_prod" {
ip_address = "203.0.113.10"
label = "Production K8S NAT Gateway"
scope = "tenant"
}
# Whitelist a CIDR range for staging
resource "secretproxy_allowed_ip" "staging_vpc" {
ip_address = "10.0.0.0/24"
label = "Staging VPC"
scope = "tenant"
}# List all targets
data "secretproxy_targets" "all" {}
# List all secrets (names only, no values)
data "secretproxy_secrets" "all" {}
output "target_names" {
value = data.secretproxy_targets.all.targets[*].name
}# main.tf — SecretProxy configuration for production
variable "secretproxy_api_key" {
type = string
sensitive = true
}
provider "secretproxy" {
api_key = var.secretproxy_api_key
}
# Register all external API targets
resource "secretproxy_target" "payment" {
name = "Payment API"
base_url = "api.acme.com"
}
resource "secretproxy_target" "email" {
name = "Email Service"
base_url = "api.email-provider.com"
}
# Bindings (secrets stored in dashboard, not Terraform)
resource "secretproxy_binding" "payment_key" {
target_id = secretproxy_target.payment.id
secret_id = 1
placeholder = "PAYMENT_KEY"
injection_type = "header"
header_name = "Authorization"
header_template = "Bearer {{value}}"
}
resource "secretproxy_binding" "email_key" {
target_id = secretproxy_target.email.id
secret_id = 2
placeholder = "EMAIL_API_KEY"
injection_type = "header"
header_name = "X-API-Key"
}
# Whitelist production infrastructure
resource "secretproxy_allowed_ip" "k8s_nat" {
ip_address = "203.0.113.10"
label = "K8S NAT Gateway"
scope = "tenant"
}
resource "secretproxy_allowed_ip" "lambda_nat" {
ip_address = "198.51.100.5"
label = "Lambda VPC NAT"
scope = "service"
}For security, the following are managed exclusively through the SecretProxy dashboard:
spk_ encryption keys are shown once in the dashboard and never stored externally.Detailed request lifecycle from client to upstream API.
secretproxy.io/{target_host}/{path} with placeholder tokens in headers or body.404.{{PLACEHOLDER_NAME}} using a regex match.400.header-type binding found in the body (or vice versa) returns 400. Templates like Bearer {{value}} are applied.| Code | Meaning |
|---|---|
400 | Unknown placeholder, policy violation, or unresolved placeholder would leak to upstream |
403 | Client IP not whitelisted or authentication failed |
404 | Target hostname not registered |
502 | Upstream API returned an invalid response |
504 | Upstream API did not respond within the timeout |
{{TOKEN}} text is never sent to the upstream API. Unresolved placeholders cause a 400 error.How placeholders map to real credentials.
Placeholders use double-curly-brace syntax: {{PLACEHOLDER_NAME}}
Names must be uppercase letters, digits, and underscores only. Examples:
{{STRIPE_KEY}}{{PAYMENT_API_TOKEN}}{{DEPLOY_KEY_V2}}{{STRIPE_LIVE_KEY}} not {{API_KEY}}._KEY, _TOKEN, _SECRET to indicate the type.Placeholders are scoped per target. The same placeholder name (e.g., {{API_KEY}}) can resolve to different secrets for different targets. A placeholder sent to a target it is not bound to will return 400.
Each (placeholder, target) pair maps to exactly one secret version. You cannot bind the same placeholder to multiple secrets for the same target.
Controlling which upstream APIs the proxy can forward to.
A target is an upstream API hostname that you register with SecretProxy. Only registered targets can receive proxied requests. Requests to unregistered hosts return 404.
When you register a target, the base URL is normalized:
https:// scheme is stripped.So https://API.Stripe.com/ becomes api.stripe.com.
You can add targets via the dashboard (Targets page) or the API (POST /api/targets). Each target has a name, base URL, and optional description.
How SecretProxy injects credentials into outgoing requests.
The most common pattern. The binding specifies injection_type: "header", a header_name (e.g., Authorization), and an optional header_template.
If a template is provided (e.g., Bearer {{value}}), the {{value}} token inside the template is replaced with the secret. The entire header value is then set on the outgoing request.
If no template is provided, the secret value replaces the placeholder directly.
For APIs that require credentials in the request body (e.g., JSON payloads), use injection_type: "body". The proxy scans the request body for the placeholder pattern and replaces it with the secret value.
Injection type is strictly enforced:
header-type placeholder found in the request body triggers a 400 error.body-type placeholder found in a header triggers a 400 error.Immutable versions, controlled rotation, and rollback.
Every secret has one or more immutable versions. When you update a secret's value (via PUT /api/secrets/:id), a new version is created. Old versions are never modified or deleted.
To rotate a credential:
promoteBindingVersion).Important: Creating a new version does not automatically update bindings. This is by design — it allows controlled rollouts where you can test the new version before switching all bindings.
To roll back, update the binding to point to the previous version. Since versions are immutable, the old value is always available.
Bindings are always pinned to a specific version. This ensures that a proxy request always resolves to the exact credential you expect, even if newer versions exist.
Add, edit, and remove upstream API targets from the dashboard.
Navigate to Targets in the dashboard sidebar. From here you can:
api.stripe.com), and an optional description.Create, rotate, and inspect secrets from the dashboard.
Navigate to Secrets in the dashboard sidebar. From here you can:
STRIPE_LIVE_KEY) and the value. The value is stored securely and never shown in full again.Connect placeholders to secrets for specific targets.
Navigate to Bindings in the dashboard sidebar. From here you can:
Each binding connects one placeholder to one secret version for one target. The combination of (placeholder, target) must be unique.
Control which IP addresses can use the proxy.
SecretProxy only accepts requests from whitelisted IP addresses. This prevents unauthorized use of your proxy bindings.
203.0.113.0/24).How to authenticate with the SecretProxy REST API.
https://api.secretproxy.ioThe API uses session cookies set when you log in through the dashboard. For programmatic access, log in via the API:
curl -X POST https://api.secretproxy.io/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "you@company.com", "password": "..."}' \
-c cookies.txt
# Use the session cookie for subsequent requests
curl https://api.secretproxy.io/api/targets -b cookies.txtAll API responses use a standard envelope:
// Success
{ "success": true, "data": { ... } }
// Error
{ "success": false, "error": "message" }
// Delete: 204 No Content (empty body)CRUD endpoints for managing upstream API targets.
List all targets.
curl https://api.secretproxy.io/api/targets -b cookies.txtCreate a new target.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Human-readable name |
base_url | string | Yes | Upstream hostname (e.g., api.stripe.com) |
description | string | No | Optional description |
curl -X POST https://api.secretproxy.io/api/targets \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"name": "Stripe", "base_url": "api.stripe.com"}'Update a target. All fields are optional.
curl -X PUT https://api.secretproxy.io/api/targets/1 \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"description": "Production Stripe API"}'Delete a target. Returns 204 No Content.
curl -X DELETE https://api.secretproxy.io/api/targets/1 -b cookies.txtCRUD endpoints for managing secrets and their versions.
List all secrets. Values are masked (**** + last 4 characters).
curl https://api.secretproxy.io/api/secrets -b cookies.txtCreate a new secret with its first version.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Secret name (e.g., STRIPE_LIVE_KEY) |
value | string | Yes | The secret value |
curl -X POST https://api.secretproxy.io/api/secrets \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"name": "STRIPE_LIVE_KEY", "value": "sk_live_abc123"}'Create a new version of an existing secret.
| Field | Type | Required | Description |
|---|---|---|---|
value | string | Yes | The new secret value |
curl -X PUT https://api.secretproxy.io/api/secrets/1 \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"value": "sk_live_newvalue456"}'Delete a secret and all its versions. Returns 204 No Content.
curl -X DELETE https://api.secretproxy.io/api/secrets/1 -b cookies.txtCRUD endpoints for managing credential bindings.
List all bindings.
curl https://api.secretproxy.io/api/bindings -b cookies.txtCreate a new binding.
| Field | Type | Required | Description |
|---|---|---|---|
external_target_id | number | Yes | Target ID |
secret_version_id | number | Yes | Secret version ID |
placeholder | string | Yes | Placeholder name (e.g., STRIPE_KEY) |
injection_type | string | Yes | header or body |
header_name | string | No | Header to inject into (for header type) |
header_template | string | No | Template with {{value}} placeholder |
curl -X POST https://api.secretproxy.io/api/bindings \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"external_target_id": 1,
"secret_version_id": 1,
"placeholder": "STRIPE_KEY",
"injection_type": "header",
"header_name": "Authorization",
"header_template": "Bearer {{value}}"
}'Update a binding. All fields are optional.
curl -X PUT https://api.secretproxy.io/api/bindings/1 \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"secret_version_id": 2}'Delete a binding. Returns 204 No Content.
curl -X DELETE https://api.secretproxy.io/api/bindings/1 -b cookies.txt