SecretSpec : le fichier .env qui se prend pour un vrai gestionnaire de secrets

Déclare tes besoins en secrets dans git, stocke les valeurs où tu veux

J’ai un truc qui me fait hurler dans tous les projets où j’atterris : la gestion de secrets à l’arrache. Tu sais, le rituel immuable :

  1. Le .env commité dans git parce que “on fera un vault plus tard”
  2. Le vault jamais mis en place, du coup les secrets traînent dans un Google Doc
  3. Le nouveau dans l’équipe qui passe deux heures à chasser les bonnes valeurs
  4. Le .env.example qui est plus à jour depuis six mois

Et le pire, c’est que tout le monde sait que c’est de la merde, mais personne prend le temps de fixer ça.

J’ai trouvé un outil qui règle le problème. Il s’appelle SecretSpec, il est fait par Cachix (oui, les gens de Cachix pour Nix), et son concept est d’une simplicité déconcertante : tu commits dans git CE DONT t’as besoin, pas LES VALEURS. Chaque environnement — ton laptop, le CI, la prod — choisit où il va chercher les valeurs.

C’est là mes définitions et si elles vous déplaisent, cassez-vous je suis pas là pour discuter lexique.

  • SecretSpec: Outil CLI/SDK qui déclare les besoins en secrets dans un secretspec.toml. Les valeurs sont stockées ailleurs.
  • Déclaration: Le fichier secretspec.toml commité dans git. Contient les noms, descriptions, contraintes. JAMAIS les valeurs.
  • Provider: Le backend qui stocke les valeurs : keyring, 1Password, Vault, fichier .env, variables d’env, etc.
  • Profile: Un ensemble de contraintes par environnement (default, production, ci). Tous héritent de default.

Regarde comment on fait habituellement. Tu as un projet. Tu as besoin d’une URL de base de données, d’une clé API Stripe, d’un token GitHub. Tu créés un .env :

DATABASE_URL=postgres://user:pass@localhost:5432/mydb
STRIPE_API_KEY=sk_live_123456
GITHUB_TOKEN=ghp_abcdef

Tu commités ça ? Non, t’es pas idiot. Tu créés un .env.example :

DATABASE_URL=postgres://user:pass@localhost:5432/mydb
STRIPE_API_KEY=
GITHUB_TOKEN=

Et là, deux problèmes :

  1. Le .env.example est toujours pas à jour. Tu ajoutes REDIS_URL dans le code mais tu oublies de le mettre dans l’exemple. Le nouveau passe une journée à debugger.
  2. Chaque environnement stocke ses valeurs différemment. Le dev a son .env local, le CI des variables d’env configurées dans GitHub Actions, la prod un vault. Et personne arrive à maintenir la synchro.

Le problème fondamental, c’est qu’on mélange deux choses : ce dont on a besoin (la déclaration) et où sont les valeurs (le stockage). SecretSpec sépare les deux.

Au lieu d’un .env, tu créés un secretspec.toml que tu commités dans git :

0[project]
1name = "mon-app"
2revision = "1.0"
3
4[profiles.default]
5DATABASE_URL = { description = "Connexion PostgreSQL", required = true }
6STRIPE_API_KEY = { description = "Clé API Stripe", required = true }
7GITHUB_TOKEN = { description = "Token GitHub", required = true }
8LOG_LEVEL = { description = "Niveau de log", required = false, default = "debug" }

Ce fichier est la source de vérité de ce dont ton application a besoin. Chaque secret a une description, un statut required ou pas, une valeur par défaut éventuelle. Et rien d’autre.

Ensuite, chaque machine configure son provider. Le développeur utilise son keyring, le CI utilise les variables d’environnement, la prod utilise Vault.

0# Sur le laptop du dev
1$ secretspec config init
2? Select your preferred provider backend:
3  keyring    # ← Il choisit le keychain macOS
4
5# En CI
6$ export SECRETSPEC_PROVIDER=env
7$ export SECRETSPEC_PROFILE=ci
8$ secretspec check --json  # Vérifie que tout est dispo

Le même fichier, partout. Chacun avec son backend.

Y en a 14. Je vais pas tous les détailler, mais voilà les plus utiles :

Provider URI Usage typique
Keyring keyring:// Développement local (keychain OS)
.env dotenv://[path] Legacy / migration
Env vars env:// CI/CD (GitHub Actions, GitLab CI)
1Password onepassword://[account@vault] Équipe (cloud, end-to-end)
Proton Pass protonpass://[vault] Équipe (cloud, chiffré)
Vault/OpenBao vault://[ns@]host Production / self-hosted
AWS Secrets Manager awssm://[profile@]REGION Production AWS
GCP Secret Manager gcsm://PROJECT_ID Production GCP
Azure Key Vault akv://VAULT_NAME Production Azure
Bitwarden BWS bws://PROJECT_ID Équipe (cloud, open source)

Tu peux aussi chaîner les providers par secret avec un fallback :

DATABASE_URL = {
  description = "PostgreSQL connection string",
  required = true,
  providers = ["team-vault", "personal-keyring", "env"]
}

Le système essaye team-vault d’abord, puis personal-keyring, puis env. Si le vault d’équipe est down, le dev bosse avec son keyring local.

T’as besoin d’une clé JWT pour ton environnement de dev ? Tu déclares ça :

JWT_SIGNING_KEY = {
  description = "Clé de signature JWT",
  required = true,
  type = "rsa_private_key",
  generate = true
}

Si le secret existe pas, SecretSpec le génère et le stocke dans ton provider. Idempotent : la prochaine fois, il trouve la valeur et passe.

Tu stockes les composants individuellement, tu reconstruis la chaîne de connexion à la volée :

DB_HOST = { description = "Hôte PostgreSQL", required = true }
DB_PORT = { default = "5432" }
DB_NAME = { description = "Nom de la base", required = true }
DB_USER = { description = "Utilisateur", required = true }
DB_PASSWORD = { description = "Mot de passe", required = true }

DATABASE_URL = {
  description = "Connection string complète",
  composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
}

T’as une base commune partagée entre plusieurs microservices ?

[project]
name = "web-api"
revision = "1.0"
extends = ["../shared/base", "../shared/auth"]

Plus besoin de dupliquer les définitions de DATABASE_URL ou JWT_SECRET dans chaque projet.

Chaque accès à un secret est logué localement. Les valeurs des secrets ne sont jamais écrites dans le log — uniquement qui a accédé, quand, et si ça a réussi.

~/.local/share/secretspec/audit.jsonl
{"timestamp":"...","secret":"DATABASE_URL","profile":"default","provider":"keyring","outcome":"success"}
{"timestamp":"...","secret":"STRIPE_API_KEY","profile":"production","provider":"vault","outcome":"success"}

Tu peux exiger qu’un agent IA fournisse une raison avant d’accéder aux secrets :

[project]
name = "web-app"
revision = "1.0"
require_reason = "agents"

Les humains en terminal interactif ne sont pas embêtés. Les agents (Claude Code, Cursor, Copilot, etc.) doivent passer --reason ou SECRETSPEC_REASON. Pratique pour savoir pourquoi un LLM a touché à tes credentials.

Étape 1 : Installation

0$ brew install cachix/secretspec/secretspec

Étape 2 : Initialisation depuis un .env existant

0$ cd mon-projet
1$ secretspec init    # Crée secretspec.toml à partir de .env
2$ secretspec config init  # Choisir un provider

Étape 3 : Commit du fichier de déclaration

0$ git add secretspec.toml
1$ git commit -m "Ajout déclaration des secrets"

Étape 4 : Vérification et lancement

0$ secretspec check
1✓ DATABASE_URL   → keyring (found)
2✓ STRIPE_API_KEY → keyring (found)
3✓ LOG_LEVEL      → default (debug)
4
5$ secretspec run -- npm start

Étape 5 : En CI (GitHub Actions)

0$ SECRETSPEC_PROFILE=ci SECRETSPEC_PROVIDER=env secretspec check --json

Le CI utilise les variables d’environnement configurées dans GitHub Secrets. Même fichier secretspec.toml, même validation, provider différent.

SecretSpec propose des SDKs pour Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, PHP et C#. Tous utilisent le même résolveur Rust derrière — donc un provider ajouté au core marche partout immédiatement.

En Rust, tu génères des structs typés depuis ta déclaration :

#[derive(SecretSpec)]
#[secretspec(file = "secretspec.toml", profile = "production")]
struct AppSecrets {
    database_url: String,
    api_key: String,
    log_level: Option<String>,
}

Plus de std::env::var("DATABASE_URL").expect("DATABASE_URL not set") à 15 endroits différents.


Parce que la situation actuelle est intenable :

  • Ton .env.example est déjà obsolète
  • Les secrets du vault sont pas synchronisés avec les besoins réels de l’appli
  • Le nouveau dans l’équipe passe deux heures à debugger pourquoi tel secret manque
  • Et tu files des tokens d’accès à des agents IA qui peuvent fouiller dans tes secrets sans laisser de trace

SecretSpec règle tout ça avec un concept simple : déclaration dans git, stockage ailleurs. Le fichier TOML est la documentation vivante et vérifiée de ce dont ton appli a besoin. Plus de secrets dans git, plus de .env à synchroniser, plus de chase aux valeurs.

Et le mieux : t’as pas à choisir un seul provider. Chaque environnement utilise le sien. Le même projet. Le même fichier. Chacun son backend.

See ya space-cowboy!