62

I try to figure out where to best store application production secrets for an ASP.NET Core app. There are two similar questions Where should I store the connection string for the production environment of my ASP.NET Core app? and How to deploy ASP.NET Core UserSecrets to production which both recommend using environment variables.

My problem is that I want to run several instances of my web app with different databases and different database credentials. So there should be some per-instance configuration including secrets.

How could this be achieved in a safe way?

Note that the application should be able to self-host and be hostable under IIS! (Later we also plan to run it on Linux if that is of any importance for the question)

Update

This question is not about trying to use ASP.NET user secrets in production! UserSecrets are ruled out for production.

Luke Girvin
  • 13,221
  • 9
  • 64
  • 84
NicolasR
  • 2,222
  • 3
  • 23
  • 38
  • 1
    regarding user secrets: you cannot use UserSecrets for production at least cause, the Secret Manager tool does not encrypt the stored secrets and should not be treated as a trusted store. It is for development purposes only. The keys and values are stored in a JSON configuration file in the user profile directory. – Set Oct 19 '16 at 17:47
  • 1
    I don't want to use UserSecrets, that's the whole point of my question! I will edit my question... – NicolasR Oct 19 '16 at 18:10

3 Answers3

24

In addition to use Azure App Service or docker containers, you can also securely store your app secrets in production using IDataProtector:

  1. App secrets are entered with running a -config switch of the application, for example: dotnet helloworld -config; In Program.Main, detects this switch to let user to enter the secrets and store in a separate .json file, encrypted:
public class Program
{
    private const string APP_NAME = "5E71EE95-49BD-40A9-81CD-B1DFD873EEA8";
    private const string SECRET_CONFIG_FILE_NAME = "appsettings.secret.json";

    public static void Main(string[] args)
    {
        if (args != null && args.Length == 1 && args[0].ToLowerInvariant() == "-config")
        {
            ConfigAppSettingsSecret();
            return;
        }
        CreateWebHostBuilder(args).Build().Run();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .ConfigureAppConfiguration((builder, options) =>
            {
                options.AddJsonFile(ConfigFileFullPath, optional: true, reloadOnChange: false);
            })
            .UseStartup<Startup>();

    internal static IDataProtector GetDataProtector()
    {
        var serviceCollection = new ServiceCollection();
            
        serviceCollection.AddDataProtection()
            .SetApplicationName(APP_ID)
            .PersistKeysToFileSystem(new DirectoryInfo(SecretsDirectory));
        var services = serviceCollection.BuildServiceProvider();
        var dataProtectionProvider = services.GetService<IDataProtectionProvider>();
        return dataProtectionProvider.CreateProtector(APP_ID);
    }

    private static void ConfigAppSettingsSecret()
    {
        var protector = GetDataProtector();

        string dbPassword = protector.Protect("DbPassword", ReadPasswordFromConsole());
        ... // other secrets
        string json = ...;  // Serialize encrypted secrets to JSON
        var path = ConfigFileFullPath;
        File.WriteAllText(path, json);
        Console.WriteLine($"Writing app settings secret to '${path}' completed successfully.");
    }

    private static string CurrentDirectory
    {
        get { return Directory.GetParent(typeof(Program).Assembly.Location).FullName; }
    }

    private static string ConfigFileFullPath
    {
        get { return Path.Combine(CurrentDirectory, SECRET_CONFIG_FILE_NAME); }
    }
}
  1. In Startup.cs, read and decrypt the secret:
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    ...
    if (env.IsProduction())
    {
        var protector = Program.GetDataProtector();
        var builder = new SqlConnectionStringBuilder();
        builder.Password = protector.Unprotect(configuration["DbPassword"]);
        ...
    }
}

BTW, appsettings.production.json or environment variable is really not an option. Secrets, as its name suggests, should never be stored in plain text.

Weifen Luo
  • 603
  • 5
  • 13
  • 6
    Is that mean every time the app starts some one need to manually enter the secret? What happen if the hosted IIS restart automatically in the middle of the night, The app will not run until user intervention. – Dush Feb 15 '20 at 20:44
  • 2
    Of course not, you only need to run your program with `-config` switch once. – Weifen Luo Feb 16 '20 at 23:56
  • How can this solution work? The database has already been opened with a connection string in the ConfigureServices method with a call to services.AddDbContext<>. When you get to the Configure method, it's too late to decrypt the db password and open the database at that point. Or am I missing something here? – BryanCass Nov 02 '20 at 17:23
  • @BryanCass Please take a closer look at the `Program.Main` method. The `-config` switch runs separately from the `CreateWebHostBuilder`. – Weifen Luo Nov 03 '20 at 01:37
  • @Weifen Luo Correct. But in Startup.cs, you must AddDbContext in ConfigureServices method. But IDataProtectionProvider is not available in that method to decrypt the stored DbPassword. Can you expand your example of your Configure method above so we can see how you are calling UseSqlServer to connect to the db in that method? – BryanCass Nov 03 '20 at 13:14
  • 1
    @BryanCass I see your point. I've updated the answer with new `Program.GetDataProtector` method. The trick is `ServiceCollection.BuildServiceProvider()`. The updated answer use a separate `ServiceCollection` to get `IDataProtectionProvider`, which is cleaner. The point is: you can get `IDataProtectionProvder` anywhere as you like. – Weifen Luo Nov 04 '20 at 01:12
  • @Weifen Luo OK thank you. So where in Startup.Configure are you calling UseSqlServer to connect to the db using your decrypted connection string? – BryanCass Nov 04 '20 at 13:26
  • 4
    You still have "protector.Protect("DbPassword", ReadPasswordFromConsole())" in your code. you are back to square one. the hacker can extract the password from your program binary. – EKanadily May 11 '21 at 23:03
  • 3
    The thing I don't really understand is why people aren't using trusted connection strings. Create a separate user account on your system either locally or Active Directory. Configure the application pool to run under that account. Configure the database to use mixed mode or windows authentication. Add the account to the database so that it can connect and define the permissions the account has on the database. Change the connection string to a trusted connection string which doesn't have the user name or the password in it. I have done for Oracle and SQL Server databases. – DerHaifisch May 14 '22 at 18:41
17

As they state, user secrets is only for development (to avoid commiting credentials accidentally into the SCM) and not intended for production. You should use one connection string per database, i.e. ConnectionStrings:CmsDatabaseProduction,ConnectionStrings:CmsDatabaseDevelopment, etc.

Or use docker containers (when you're not using Azure App Service), then you can set it on per container basis.

Alternatively you can also use environment based appsetting files. appsettings.production.json, but they must not be included in the source control management (Git, CSV, TFS)!

In the startup just do:

    public Startup(IHostingEnvironment env)
    {
        var builder = new ConfigurationBuilder()
            .SetBasePath(env.ContentRootPath)
            .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
            .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
            .AddEnvironmentVariables();
        Configuration = builder.Build();
    }

This way, you can load specific stuff from the appsettings.production.json and can still override it via environment variable.

Tseng
  • 61,549
  • 15
  • 193
  • 205
  • 2
    I am aware of the problem with user secrets during development time. But that is not the problem here, I am only talking about production time! Environment settings files don't help because it would still be the same for each instance in production and overriding environment variables would still be the same for all instances! I don't now enough about Docker yet but maybe that idea could help. – NicolasR Oct 19 '16 at 13:23
  • 2
    @NicolasR: Not necessarily. You'd have one application.production.json per installation. If you have your application in 3 different folders, you could set each of their `applciation.production.json´ to the desired value. You just have to make sure to exclude `applciation.production.json´ from source control, because it now contains sensitive data. – Tseng Oct 19 '16 at 13:49
  • 1
    There is also `Microsoft.Extensions.Configuration.CommandLine` which can read configuration from command line parameters, but its not easily used within ASP.NET Core as you build your configuration inside Startup class and you don't have easy access here from there to the `args` parameter which is passed to the `Main` method – Tseng Oct 19 '16 at 13:49
  • On top of that, even with user secrets you are constraints to a per-installation, as the user secret token is inside the project.json file and so per installation of your application, but you can do that with environment variables though. before you run `dotnet run`, you set the ASPNETCORE_ENVIRONMENT variable, which you can read via `env.EnvironmentName` in Startup to decide which `appsettings.*.json` file to load. This of course doesn't work in IIS, unless you set up multiple applications/folders with different ASPNETCORE_ENVIRONMENT variable – Tseng Oct 19 '16 at 13:51
  • see https://www.iis.net/configreference/system.webserver/fastcgi/application/environmentvariables on how to do that in IIS. Linux it's a matter of `ASPNETCORE_ENVIRONMENT=Development dotnet run` – Tseng Oct 19 '16 at 13:56
  • thanks, that is all very interesting. Parallel installation in separate directories would definitely work which brings me to a final question: one often reads that config files should never contain passwords, at least not plain text. Is this statement only valid for development config files because of the source control issue? So, would you store the production connection string including the plain text password in the config file? – NicolasR Oct 19 '16 at 18:20
  • 5
    @NicolasR: Well, as far as I know, there is no "secure string" like the one from legacy ASP.NET applications where you could put an encrypted string into the web.config and have it decrypted at runtime, so unless you write your own decryption method (which would read the encrypted string and decrypt it via private/public key), there is no way to store it. But that's so big impact. Your app needs to access the private key to decrypt the string and needs the password of this private key, so you will still have to have it accessible by the app – Tseng Oct 19 '16 at 18:52
  • 8
    And if someone hacks into your server, he can also gain access to your private key and password and your application (in case password is hardcoded inside). C# Code compiled to CIL offers no security at all. So someone who gains access to your server, will have the means of gaining access to your passwords anyways one way or another. And even if they'd able to obtain the password, your database shouldn't be internet facing and only allow connections from your network/IPs/local. Security this properly offers more security than encrypting an connection strings (as you have to decrypt them) – Tseng Oct 19 '16 at 18:55
  • @Tsend: Ok, all very good points and probably enough to solve the questions. Thanks for all the informations. – NicolasR Oct 20 '16 at 09:20
  • 3
    @Tseng If you don't add your appsettings.{whatever}.json to your repository, how do you expect any CI/CD pipeline to know specific settings per environment when deploying the application. That simply doesn't work. appsettings files ARE NOT the place for secrets but for settings. They MUST be added to Git (or whatever else you use) in order to be used by your application. – Pepito Fernandez Jul 08 '18 at 18:51
  • @Tseng Additionally, people should be using trusted connection strings instead of connection strings with usernames/passwords. – DerHaifisch May 14 '22 at 18:44
4

If your application is hosted on AWS, this solution will probably be the simplest. It relies on the SecretConfiguration.AwsKms NuGet package.

Secrets are stored in a separate configuration file in encrypted form. The secrets are then decrypted at runtime using AWS Key Management Service. This way you can version your secrets along with your application's source code, while avoiding storing secrets in clear text.

Concretely, it's a configuration provider that integrates with the Microsoft.Extensions.Configuration stack.

Here are the steps:

1. Create a KMS key

Use the AWS console or CLI to create a KMS key (symmetric encryption). Make sure your developers have the permission to encrypt but not decrypt, and that only the role used to run your application has decrypt permissions.

2. Encrypt your secrets

Use the AWS CLI to encrypt your secrets. The command to use is the following (replace the key-id with the key ID of your KMS key):

aws kms encrypt --cli-binary-format raw-in-base64-out --key-id "11111111-0000-0000-0000-000000000000" --plaintext "SECRET_TO_ENCRYPT"

The output will contain the ciphertext:

{
    "CiphertextBlob": "AQICAHhDR/VQh6Ap...rfyKsKCG2h6WVK8=",
    "KeyId": "arn:aws:kms:eu-west-1:123456789:key/11111111-0000-0000-0000-000000000000",
    "EncryptionAlgorithm": "SYMMETRIC_DEFAULT"
}

Repeat this step for all the secrets you have in your application.

3. Create a separate configuration file for secrets

This file looks like a normal configuration file you would have in an ASP.NET Core application, except the string values are encrypted. For example, it may looks like:

{
  "Database": {
    "Password": "AQICAHhDR/VQh6Ap...rfyKsKCG2h6WVK8="
  },
  "Redis": {
    "Password": "AQICAHhDR/VQh6Ap...47iiHg/XifWcxvQ="
  }
}

You can also have one file per environment if you like (e.g. secrets.Staging.json and secrets.Production.json).

4. Register the configuration at startup

In your application startup, where configuration sources are configured, add this new configuration source:

string keyId = "arn:aws:kms:eu-west-1:123456789:key/11111111-0000-0000-0000-000000000000";

builder.Configuration.AddAwsKmsEncryptedConfiguration(
    new AmazonKeyManagementServiceClient(),
    keyId,
    encryptedSource => encryptedSource
        .SetBasePath(builder.Environment.ContentRootPath)
        .AddJsonFile($"secrets.{builder.Environment.EnvironmentName}.json"));

This will have the effect of transparently decrypting the secrets at runtime, and merging the key/value configuration pairs with the rest of the configuration sources already configured.

5. Access your secrets the same way you normally access configuration settings

Now you can use the configuration just like you are used to doing with IConfiguration:

IConfiguration configuration;

// Comes from your regular configuration file
string databaseLogin = configuration["Database:Login"];
// Comes from the encrypted configuration file
string databasePassword = configuration["Database:Password"];
Flavien
  • 7,497
  • 10
  • 45
  • 52
  • Really nice way to handle production secrets. It is mentioned in this answer but, like me, for those who missed it: add the nuget packages for SecretConfiguration.AwsKms and AWSSDK.KeyManagementService for step 4 Also, every secret in the secrets file needs to be encrypted. – Ash Dec 13 '22 at 12:45
  • What is the CLI command to get the plaintext from the ciphertext? Tried the `aws kms decrypt` command with the ciphertext-blob parameter set to what i get from the encrypt command but the plain text value is different. I think I just need the equivalent of `--cli-binary-format raw-in-base64-out ` for the decrypt command. – Ash Dec 13 '22 at 23:16