Darkwood Blog Blog
  • Articles
  • Watch
  • Releases
  • Creators
en
  • de
  • fr
Login
  • Blog
  • Articles
  • Watch
  • Releases
  • Creators

🔑 Secret Keys Become Application Data

on September 20, 2026

Log in to add a reaction to this post

🚀 1

🔑 Secret Keys Become Application Data

Most Symfony applications I write still treat the model key as infrastructure:

ANTHROPIC_API_KEY=...

That line is simple because ownership is simple. The credential belongs to the deployment. Operators put it in .env.local or a secrets vault. The container injects it once at boot. The application never has to accept it, store it, or decide when it is allowed to exist in memory.

Bring-your-own-key changes the owner.

What happens when the key belongs to the user instead?

It arrives through HTTP. It is attached to an application entity. It has to survive across requests. It must not be stored in plaintext, must not be painted back into HTML, and must be decrypted only when something actually needs to call a provider. That provider can spend money.

.env is no longer the answer. The credential has become application data.

This week's experiment, navi-key-management, is a small Symfony 8.2 app that implements that lifecycle and then measures it. I am reporting what ran on 2026-09-19: PHP 8.5.4, Symfony 8.2.x-dev, SQLite, local Sodium. Symfony KeyManagement is experimental. Its installed docblocks say so. Treat every signature here as that snapshot, not as a promise about the 8.2 stable release.

BYOK Changes the Boundary

The path I coded is the path I wanted to be able to read:

User
  ↓
Symfony Form
  ↓
KeyManagement encrypt
  ↓
SQLite ciphertext
  ↓
decrypt at execution time
  ↓
Darkwood Navi
  ↓
Symfony AI
  ↓
Anthropic / provider

Each arrow is a different kind of work. The form does not know how a chat request is built. Navi does not persist secrets. Symfony AI does not own the database row. KeyManagement does not talk to Claude.

I did not add a repository layer or a second AI SDK. The interesting abstractions were already in the stack. One of them was easy to misname.

darkwood/navi is the workflow shell: Action, Context, WorkflowRunner, Event. It has no Anthropic client and no type for an API key. Symfony AI is what talks to the model. The execution chain in this repository is:

AiConnection
    ↓
CredentialProtector::decrypt()
    ↓
WorkflowRunner
    ↓
TestAiConnectionAction
    ↓
Anthropic\Factory::createPlatform($apiKey)
    ↓
provider

Navi orchestrates a step. It does not replace Symfony AI.

There was a packaging friction that belongs in the story because it is real. Packagist darkwood/navi ^1.0 still requires Symfony 8.0. This app needs 8.2.x-dev, so Navi is a path repository onto the local monorepo (8.1.x-dev). Experimenting ahead of released constraints looks like that: a symlink, not a slogan.

Symfony Gets a Key Management Primitive

Once the key is application data, the first missing piece is not another AI wrapper. It is a way to encrypt a small secret without inventing a crypto service.

Symfony 8.2 KeyManagement is that primitive. I installed symfony/key-management and symfony/aws-key-management at 8.2.x-dev and used the interfaces the container actually autowires:

$ciphertext = $this->encrypter->encrypt($this->keyId, $apiKey);
$connection->setCiphertextBlob(base64_encode($ciphertext->blob));
$connection->setKmsKeyId($ciphertext->keyId);

encrypt() returns a Ciphertext, not a string. The row keeps the blob and the key id together. The application injects EncrypterInterface and DecrypterInterface. Envelope encryption exists on the same bundle. I did not use it. An API key is hundreds of bytes, well under the 4 KB direct-encrypt limit on AWS KMS. Envelope is for files and large documents, or for a policy that says the KMS must never see application plaintext. It is not mandatory because the component ships it.

The configuration I ended up with is not the one-line DSN I first tried.

key_management:
    clients:
        app: 'sodium://?keys[app-key]=%env(DEV_KMS_KEY)%'
    default_client: app

when@prod:
    key_management:
        clients:
            app: 'aws-kms://default?region=%env(AWS_KMS_REGION)%'

A bare '%env(KMS_DSN)%' does not compile. The experimental config tree marks the client DSN cannotBeEmpty, and a fully substituted env var is treated as empty at compile time:

The path "key_management.clients.app" cannot contain an environment variable
when empty values are not allowed by definition and are validated.

The working pattern keeps a literal scheme — sodium:// in dev/test, aws-kms:// in prod — and env-substitutes only the key material or the region.

dev / test
    ↓
Sodium          ← tested in this repository

prod
    ↓
AWS KMS         ← configured, not called

Sodium is why the experiment is locally reproducible. The process possesses DEV_KMS_KEY. I generated it with sodium_crypto_aead_xchacha20poly1305_ietf_keygen() and Base64UrlSafe. Anyone who can read that env value can decrypt every stored API key. That is acceptable for a laptop demo. It is not the production property.

AWS KMS is the external boundary the prod DSN points at. The AWS bridge encrypts server-side; the master key is supposed to stay in KMS. I did not send a request to AWS. The prod block is configuration, not a test result.

The UI badge does not parse the DSN. KMS_BACKEND is a label (Sodium or AWS KMS) so key material never becomes a Twig variable.

The committed development key in the experiment .env is a development key only. Generate a private Sodium key before using the pattern for anything real.

A Small Experiment

The model is one entity, AiConnection: label, provider, model, optional endpoint, ciphertextBlob, kmsKeyId, timestamps. There is no plaintext API-key column.

The UI is four routes and a dark panel. It exists to make the lifecycle visible: a list with encrypted and backend badges, a form, a test action, a result page. It is not a dashboard product.

GET        /
GET|POST   /connections/new
GET|POST   /connections/{id}/edit
POST       /connections/{id}/test

That is the whole application surface.

The Form Is Now a Security Boundary

The key used to enter the process through dotenv. It now enters through a form. That makes the form part of the security design, not a skin on a DTO.

Symfony 8.2 can derive the form type from the class that holds the data. This DTO is five fields. That is the size the new attributes are for.

#[AsFormType]
final class AiConnectionInput
{
    #[FormField(ChoiceType::class, [
        'choices' => [
            'Anthropic' => 'anthropic',
            'OpenAI' => 'openai',
        ],
    ])]
    public string $provider = 'anthropic';

    #[FormField(PasswordType::class, [
        'always_empty' => true,
        'attr' => ['autocomplete' => 'new-password'],
    ])]
    #[Assert\NotBlank(groups: ['create'])]
    public ?string $apiKey = null;
}

createForm(AiConnectionInput::class, $input) is enough. There is no AbstractType. The compiled name is ai_connection_input.

The security behaviour I wanted is in the attributes and the controller together. The key field is a password input. always_empty keeps the browser from showing a value. On edit, the controller copies label, provider, model, and endpoint onto the DTO and leaves apiKey null. A blank submit does not call encrypt(); the existing ciphertext stays. NotBlank is only in the create group.

Attribute-based forms removed a class I would otherwise have written. They did not remove validation groups or CSRF. The Flex stateless-CSRF recipe left value="csrf-token" in the HTML and expected a Stimulus controller this app does not ship. Session CSRF made a normal browser submit work. That is a small 8.2 lesson sitting next to the large one: new form attributes are a good fit for a configuration DTO, and they do not abolish the rest of the form stack.

Ciphertext in SQLite

After a browser submit of a fake key, SQLite held metadata plus a blob:

$ sqlite3 var/data.db "SELECT kms_key_id, length(ciphertext_blob), instr(ciphertext_blob, 'sk-ant') FROM ai_connection;"
app-key|96|0

instr(ciphertext_blob, 'sk-ant') = 0. A LIKE for the submitted value was also 0. The stored text is opaque base64 of Ciphertext::$blob.

That is the strongest persistence evidence in the week, and it has a clear limit. It proves the recognizable plaintext is absent from that column. It is not a cryptographic audit. It does not prove the cipher is Sodium XChaCha, does not prove nonce handling, and does not prove an attacker with the development master key is blocked. PHPUnit makes the same absence check on the HTTP response: the key I posted does not come back in the HTML.

The list page shows encrypted and Sodium. It does not show the key.

Decrypt at the Last Responsible Moment

Listing a connection does not decrypt anything. Editing it does not decrypt anything. The key is reconstructed only when an AI action needs it.

$apiKey = $this->credentials->decrypt($connection);

try {
    $result = $this->workflows->run(
        Context::fromArray([
            'connection_id' => $connection->getId(),
            'provider' => $connection->getProvider(),
            'model' => $connection->getModel(),
        ]),
        [new TestAiConnectionAction($apiKey, /* provider, model, endpoint */)],
    );
} finally {
    $apiKey = str_repeat("\0", \strlen($apiKey));
    unset($apiKey);
}
stored ciphertext
       ↓
Test connection / probe
       ↓
decrypt
       ↓
TestAiConnectionAction constructor
       ↓
Factory::createPlatform($apiKey)
       ↓
one prompt

The Action marks the constructor argument #[\SensitiveParameter]. Context carries identifiers. It does not carry the secret. That is deliberate: Navi Context has toArray(). Putting the key there would turn orchestration state into a leak.

app:probe-connection reported context_has_secret: no after a live run. Navi can orchestrate the call without making the credential workflow data.

Inside the Action, Symfony AI is constructed at execution time. A container-level ANTHROPIC_API_KEY in ai.yaml would have defeated BYOK.

AnthropicFactory::createPlatform(
    $this->apiKey,
    $this->httpClient,
    baseUrl: $this->endpoint ?: 'https://api.anthropic.com',
);

The prompt is one line: Reply with the single word pong. If the HTTP client throws a message that contains the key, SecretRedactor replaces that substring before the error reaches Twig.

A Failed Request Can Still Tell Us Something

I used a locally available Anthropic credential through app:probe-connection. I am not going to print it.

plaintext_in_ciphertext: no
decrypt_matches: yes
context_has_secret: no
test_ok: false
test_error: Your credit balance is too low to access the Anthropic API. ...

Encrypt and decrypt matched. The key was absent from Navi Context. The request left the process and reached Anthropic. Anthropic answered with a billing error.

A billing error is still evidence about how far the request travelled. An invalid-key response would have meant we never authenticated. This is further than that.

It is not a successful completion. It is not token usage. It is not a receipt. The UI path with a fake key showed API key is invalid. and nothing secret. Both error paths were useful. Neither one is a completed chat.

Keys Have an Economic Blast Radius

Guillaume Moigneu's "Cost of a diff" writing is the source of an idea, not of this code: an AI action has an observable economic cost. Input tokens, output tokens, cached tokens, a model, a rate.

A secret that unlocks an AI provider is therefore not only access control. It protects something that can spend money.

The application has the accounting path. TestAiConnectionAction reads Symfony AI TokenUsageInterface when the invoke result carries token_usage:

prompt tokens        → input
completion tokens    → output
cache creation       → cache_write
cache read           → cache_read
cached tokens        → cached

Missing metrics stay null and render as "not reported". AiCostTable then applies an explicit per-1M USD list dated 2026-09-19:

usd = input  × input_rate  / 1_000_000
    + output × output_rate / 1_000_000
    + cache_write × cache_write_rate / 1_000_000   (if the table has one)
    + cache_read  × cache_read_rate  / 1_000_000   (if the table has one)

Claude Sonnet 4.5 in that table is $3 / $15 / $3.75 / $0.30. A local dry-run with invented counts (42 in, 8 out) produces $0.000246. That number is a spreadsheet check. It is not a provider receipt.

No successful paid completion was made this week. The cost path exists. It was not validated against usage metadata from Anthropic. I will not invent tokens to make the UI look finished.

Provider Is Not Endpoint

A Symfony AI discussion this week reported that Claude through an Azure AI subscription worked by pointing the existing Anthropic bridge at an Anthropic-compatible messages URL, rather than by adding a new Azure-Claude abstraction.

The reported shape was:

<resource>.services.ai.azure.com/anthropic/v1/messages

The reported test included a complete chat round and prompt caching. I am not repeating private identities from that thread.

The installed Anthropic Factory::createPlatform() already has $baseUrl. ModelClient posts to {baseUrl}/v1/messages. The form's optional endpoint is therefore the Azure base without that suffix:

https://<resource>.services.ai.azure.com/anthropic

baseUrl is wired. Azure Anthropic was not called.

Bedrock is not the same trick. It authenticates with AWS credentials, not an Anthropic key plus a host. Compatibility was an endpoint problem for Azure's gateway. It is an authentication-model problem for Bedrock. I did not add a Bedrock layer.

Provider identity, transport endpoint and credential storage are separate concerns.

Sometimes compatibility is configuration, not another abstraction. That is the same habit as keeping Navi out of persistence and Symfony AI out of the form.

Sodium Locally, KMS Outside

I am not going to turn this into a cryptography tutorial. The operational difference is enough.

With Sodium, the application possesses the encryption key and can decrypt every row it wrote. The experiment is runnable without an AWS account. Compromise of DEV_KMS_KEY is compromise of the stored API keys.

With AWS KMS, the application asks an external service. The master key is supposed to remain outside application storage. Compromise of .env is not enough; an attacker still needs AWS credentials and a key policy that allows decrypt.

That is a key-management improvement. It is not magic protection against a compromised runtime. If the running application can call decrypt(), a sufficiently present attacker can call it too. The gain is that the master key is not a byte string sitting next to the ciphertext, and that encrypt/decrypt can be audited outside the app. I verified the first world. I configured the second.

AI Changes Both Sides of Security

Fabien Potencier wrote this month that Symfony used to see about one security report a quarter and now sees at least one a day. Agents scan a fresh release as soon as it ships. A report in the inbox should be treated as already public. Embargoes assumed discovery was expensive.

That is not a CVE in this application. I am not attaching this experiment to a numbered advisory.

The useful connection is economic. Agents lower the cost of searching a codebase for the place a secret leaks. At the same time, an AI credential has a direct financial value: it can be spent. Those two facts together are why BYOK handling belongs in application architecture instead of in deployment housekeeping.

The concrete decisions in the repository are ordinary on purpose: no plaintext column, password input, no redisplay, decrypt only for execution, key absent from Navi Context, fake .env.example, backend badge without a DSN. KeyManagement does the encryption. The rest is hygiene applied because the object we are protecting is now a row.

What Actually Worked

These checks ran.

Container, Twig and YAML linted clean.

PHPUnit: 2 tests, 15 assertions, OK. One test encrypts and decrypts through Sodium and asserts the plaintext is absent from the entity blob. The other posts the attribute-based form and asserts the key is absent from SQLite and from the HTML.

In the browser I added a connection, saw the encrypted and Sodium badges, opened edit with a blank API-key field, and tested a fake credential. The provider answered API key is invalid.

SQLite after that submit:

instr(ciphertext_blob, 'sk-ant') = 0

The live Anthropic probe:

encrypt/decrypt: matched
credential in Navi Context: no
provider reached: yes
result: billing error — credit balance too low

Those are the conclusions I am willing to stand on.

What I Did Not Verify

AWS KMS was configured and not called.

Azure Anthropic baseUrl was wired and not called.

No successful paid Anthropic completion. Therefore no real token receipt, and cost accounting was not validated against a successful provider response.

KeyManagement remains experimental.

Implemented, configured, tested, and verified are four different words. This week used all four. I am not collapsing them.

Conclusion

.env is a good model when the secret belongs to the deployment.

BYOK changes ownership.

Once a credential belongs to an application user, it needs an application lifecycle: input, encryption, persistence, execution, observability, and eventually rotation. Symfony 8.2 KeyManagement is a useful primitive for the encryption step. It is experimental, and its config tree is picky about env vars, but the interfaces I ran — encrypt a small secret, persist a Ciphertext, decrypt at the last moment — are the ones BYOK actually needs.

Navi does not need to become a secret manager. Symfony AI does not need to own persistence. The form does not need to know how the AI request is built.

Each layer can stay small. The key is no longer a line in .env. It is a row, a ciphertext, a constructor argument, and a line item. That is the whole shift.

Log in to add a reaction to this post

🚀 1

Site

  • Sitemap
  • Contact
  • Legal mentions

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, all rights reserved