The Mistake That Keeps Happening
I have seen live API keys committed to public GitHub repositories more times than I should have. SendGrid keys. Stripe secret keys. Database connection strings. OpenAI keys with active billing. All sitting in source code, visible to anyone who finds the repository.
Most of the time it is not malicious. It is a developer moving fast who hardcoded a key to test something quickly, meant to move it to an environment variable later, and either forgot or assumed the repo was private. Then the repo gets made public. Or a collaborator is added. Or GitHub's secret scanning catches it — after it has already been indexed.
The consequences range from an unexpected bill to a full data breach. The prevention takes 30 seconds.
What Can Go Wrong With a Leaked Key
The impact depends on which key is exposed, but none of the scenarios are good:
- Stripe secret key — an attacker can retrieve customer data, issue refunds, create charges, and access your full payment history. Stripe will hold you liable for fraudulent activity conducted with your own key.
- SendGrid or email API key — an attacker can send emails from your domain at scale. This destroys your sender reputation, gets your domain blacklisted by email providers, and can be used for phishing campaigns that trace back to you.
- Database connection string — direct read and write access to your production database. Every user record, every transaction, every piece of data you store.
- OpenAI or AI service key — the attacker runs queries at your expense. API costs can spike to hundreds or thousands of dollars before you notice.
- Cloud provider credentials (AWS, Azure, GCP) — potentially the most severe. An attacker with cloud credentials can spin up infrastructure at your cost, access all services in your account, and exfiltrate or destroy data.
GitHub's secret scanning catches many of these automatically and notifies you — but the window between a push and the notification is enough for automated bots that continuously scan public commits to find and use the key first.
The Correct Pattern
The rule is simple: configuration structure lives in code, configuration values live in environment variables.
What Goes in appsettings.json (Safe to Commit)
{
"SendGrid": {
"ApiKey": "",
"FromEmail": "",
"FromName": ""
},
"Stripe": {
"SecretKey": "",
"PublishableKey": "",
"WebhookSecret": ""
},
"ConnectionStrings": {
"DefaultConnection": ""
},
"HCaptcha": {
"SecretKey": "",
"SiteKey": ""
}
}
This file defines the shape of your configuration — which keys exist, how they are grouped — without containing any real values. It is safe to commit because it contains nothing sensitive.
What Goes in Environment Variables (Never Committed)
# .env (local development only — in .gitignore)
SendGrid__ApiKey=SG.xxxxxxxxxxxxxxxxxxxxxxxx
SendGrid__FromEmail=hello@yourdomain.com
SendGrid__FromName=Your Name
Stripe__SecretKey=sk_live_xxxxxxxxxxxxxxxx
Stripe__WebhookSecret=whsec_xxxxxxxxxxxxxxxx
ConnectionStrings__DefaultConnection=Server=yourserver;Database=yourdb;...
HCaptcha__SecretKey=0xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Note the double underscore (__) separator. In .NET, this maps directly to the nested structure in appsettings.json — SendGrid__ApiKey maps to SendGrid:ApiKey. This is the standard .NET configuration binding convention.
Reading Configuration in C#
// Injected via IConfiguration — never hardcoded
public class EmailService
{
private readonly string _apiKey;
private readonly string _fromEmail;
public EmailService(IConfiguration config)
{
_apiKey = config["SendGrid:ApiKey"]
?? throw new InvalidOperationException(
"SendGrid API key is not configured. Set the SendGrid__ApiKey environment variable.");
_fromEmail = config["SendGrid:FromEmail"]
?? throw new InvalidOperationException(
"SendGrid from email is not configured.");
}
}
The ??throw pattern is important. It makes a missing configuration value fail loudly at startup rather than silently at runtime when the service is first called. You want to know immediately if a required key is missing — not when a user tries to reset their password and gets an unhandled exception.
Strongly Typed Configuration (Preferred for Larger Projects)
For projects with multiple configuration sections, strongly typed options classes are cleaner than reading string keys directly:
// SendGridOptions.cs
public class SendGridOptions
{
public string ApiKey { get; set; } = string.Empty;
public string FromEmail { get; set; } = string.Empty;
public string FromName { get; set; } = string.Empty;
}
// Program.cs
builder.Services.Configure<SendGridOptions>(
builder.Configuration.GetSection("SendGrid")
);
// EmailService.cs
public class EmailService
{
private readonly SendGridOptions _options;
public EmailService(IOptions<SendGridOptions> options)
{
_options = options.Value;
if (string.IsNullOrWhiteSpace(_options.ApiKey))
throw new InvalidOperationException("SendGrid API key not configured");
}
}
This approach gives you compile-time safety on configuration property names and makes it obvious which settings a service depends on.
The .gitignore Setup
Add your secrets file to .gitignore before the first commit. Once a file has been committed, removing it from .gitignore does not remove it from git history — it remains accessible in older commits forever unless you rewrite history.
# .gitignore
.env
.env.local
.env.development
.env.production
*.secrets.json
appsettings.Development.json # if you put real values here
The standard .NET gitignore generated by dotnet new gitignore already includes common secret file patterns — but verify it covers what you are using before your first push.
Where Secrets Live in Each Environment
Local Development
Two good options:
.env file — simple, works with any stack, just make sure it is in .gitignore.
.NET User Secrets — stored outside the project directory in your user profile, so there is no risk of accidentally committing them:
dotnet user-secrets init
dotnet user-secrets set "SendGrid:ApiKey" "SG.your_key_here"
dotnet user-secrets set "Stripe:SecretKey" "sk_live_your_key_here"
User secrets are automatically loaded in the Development environment and never stored in the project folder.
Production
Use your platform's native secrets management:
- Vercel — Environment Variables in project settings. These are injected at build time and runtime, never stored in your code.
- Azure App Service — Application Settings in the portal. These override appsettings.json values and are encrypted at rest.
- Azure Key Vault — for enterprise scenarios where secrets need auditing, rotation policies, and access control.
- AWS — Secrets Manager or Parameter Store, depending on requirements.
- Docker / self-hosted — pass environment variables in your docker-compose.yml or deployment configuration, sourced from a secrets manager rather than hardcoded.
The rule of thumb: the more sensitive the key, the more formal the secrets management should be. A personal project on Vercel uses environment variables in the dashboard. An enterprise system with PCI compliance requirements uses Key Vault with access policies and rotation.
What to Do If You Have Already Committed a Key
If you have already pushed a secret to a repository — public or private — treat it as compromised immediately. Do not wait to see if anyone noticed.
- Rotate the key immediately — go to the service's dashboard and generate a new key. Invalidate the old one.
- Audit the service for unauthorized usage — check Stripe for unexpected charges, SendGrid for unusual sending volume, your database for unexpected connections.
- Remove the secret from the repository — either rewrite git history (git filter-branch or git filter-repo) or, for public repositories, assume the key has already been scraped and focus on rotation rather than removal.
- Add the secrets file to .gitignore before committing the new key.
Rewriting git history is disruptive and does not guarantee the key was not already indexed. Rotation is the only reliable remediation.
A Practical Checklist
Before any project's first commit:
- .gitignore created and includes .env and secrets files
- appsettings.json contains structure but no values
- All real credentials in environment variables or user secrets
- Configuration read via IConfiguration or IOptions, never hardcoded
- Missing required configuration throws at startup, not at runtime
Before any project goes to production:
- All secrets loaded from the platform's environment variable system
- No .env files on the production server
- Production keys are different from development keys (separate Stripe accounts, separate SendGrid API keys)
- Minimum required permissions for each key — a key that only sends email should not have admin access to the account
It Takes 30 Seconds
The correct pattern is not significantly more work than hardcoding a key. Setting up a .env file and reading from IConfiguration takes the same amount of time as copy-pasting a key directly into code. The difference is that one of those approaches can create a serious security incident six months from now, and the other cannot.
I follow this pattern on every project I build — from the Embassy of Kenya platform with its SendGrid integration to the hCaptcha-protected contact forms on smaller sites. The configuration structure is always committed. The values never are.
If you are building an integration with any external service and want to make sure it is set up securely from the start, get in touch.