Use your credentials
Once a request is Ready, its credentials sit in the vault at kv/teams/<team>/oauth-clients/<team>-<name>. Every method below gets them the same way: whoever or whatever needs them signs in with its own identity, and the vault checks that identity belongs to the owning team.
Choose a method
| You are | Use | Signs in with | Where the secret ends up |
|---|---|---|---|
| A person on the team, looking or testing | Vault CLI or UI | Your normal login (OIDC) | Your terminal, briefly |
| A job or service outside Kubernetes | Python sample | A JWT from the identity provider | process memory only |
| A pod that can call the vault itself | Python sample, Kubernetes mode | The pod's service account | process memory only |
| A pod that expects environment variables or files | External Secrets | The team's courier-secrets service account | a Secret in your namespace |
Reading from the vault at start-up keeps the secret out of every other system. External Secrets is easier for existing applications, but it places a copy in a Kubernetes Secret, so anyone who can read Secrets in your namespace can read it. Prefer direct reads for new code; use External Secrets when changing the application is not practical.
People: Vault CLI
export VAULT_ADDR=https://vault.sororlab.dev
vault login -method=oidc # opens your browser; uses your identity-provider groups
vault kv get kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter
vault kv get -field=client_id kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter
The web UI works the same way: sign in with method OIDC and browse to kv/teams/<team>/. You only see your own team's paths.
Workloads: the Python sample
samples/python/courier_credentials.py is a small, dependency-light module built on hvac, the standard Python client for Vault. Copy it into your service or use it as a pattern.
pip install -r samples/python/requirements.txt # hvac==2.4.0
In code
from courier_credentials import vault_client, read_credentials, client_credentials_token
creds = read_credentials(vault_client(), "team-bravo", "report-exporter")
print(creds) # repr never includes client_secret
token = client_credentials_token(creds, extra={
"username": os.environ["AUTHENTIK_USERNAME"], # Authentik identifies the service account
"password": os.environ["AUTHENTIK_PASSWORD"],
})
Configuration
| Variable | token mode | jwt mode | kubernetes mode |
|---|---|---|---|
COURIER_VAULT_AUTH | token (default) | jwt | kubernetes |
VAULT_ADDR | required, e.g. https://vault.sororlab.dev or http://vault-active.vault.svc:8200 in-cluster | ||
| Identity | VAULT_TOKEN or ~/.vault-token | COURIER_JWT or COURIER_JWT_FILE | pod service account token |
VAULT_ROLE | unused | default machine | your workload's Kubernetes auth role |
What it checks for you
| Situation | What you get |
|---|---|
| Identity not in the owning group | PermissionError: not allowed to read kv/teams/team-bravo/…: is this identity in IdP group 'team-bravo'? |
| Request not merged, or retired | LookupError: nothing at kv/teams/…: is the request merged and Ready? |
| Courier still delivering | CredentialsNotReady: … is 'pending'; wait for the OAuthClient to be Ready |
| Public client | client_secret is None; client_credentials_token refuses |
The per-team courier-secrets service account exists for External Secrets. To let a pod read the vault directly, ask the identity team for a Kubernetes auth role bound to your workload's own service account with your team's policy.
Kubernetes: External Secrets
Every team directory under requests/ automatically gets two objects in its namespace, managed by ArgoCD application courier-team-access-<team>:
| Object | Purpose |
|---|---|
ServiceAccount courier-secrets | The identity External Secrets uses to sign in to the vault; no pod mounts it |
SecretStore courier-vault | Signs in with Kubernetes auth role <team>, which carries only your team's read policy |
Add an ExternalSecret next to your application's manifests (not in requests/):
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: report-exporter-oauth
namespace: team-bravo
spec:
refreshInterval: 1h
secretStoreRef:
name: courier-vault
kind: SecretStore
target:
name: report-exporter-oauth
creationPolicy: Owner
deletionPolicy: Delete # remove the Secret when the request is retired
dataFrom:
- extract:
key: teams/team-bravo/oauth-clients/team-bravo-report-exporter
The Secret gets one key per vault field (client_id, client_secret, token_endpoint and so on). Use it like any Secret:
envFrom:
- secretRef:
name: report-exporter-oauth
# each vault field becomes an env var: client_id, client_secret, token_endpoint, ...
| When | What happens to the Secret |
|---|---|
| Request changes (scopes, groups) | Updated at the next refresh; the secret value is unchanged |
| Secret re-issued | Updated at the next refresh (up to refreshInterval); restart pods that read it only at start-up |
| Request retired | With deletionPolicy: Delete, removed once the vault entry is gone |
| ExternalSecret points at another team's path | Never created; status SecretSyncedError, and the vault logs permission denied |
Verified in the reference environment
Both methods were run against team-bravo's report-exporter client, delivered by pull request #1, and against team-alpha as the team that must be refused.
Python sample (samples/python/verify.sh)
--- as team-alpha workload: python courier_credentials.py team-bravo report-exporter
error: not allowed to read kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter: is this identity in IdP group 'team-bravo'?
--- as team-bravo workload: python courier_credentials.py team-bravo report-exporter
OAuthClientCredentials(path='kv/teams/team-bravo/oauth-clients/team-bravo-report-exporter',
client_id='8BhnTaEg83o1lfhlwLSbBqHL6N5rMGOdsUi9rfA9', client_type='confidential',
grant_types=('client_credentials',), scopes=('openid', 'profile', 'groups'), ...,
owner_group='team-bravo')
client_secret: present (64 chars)
--- as team-bravo workload: python get_token.py team-bravo report-exporter
client 8BhnTaEg83o1lfhlwLSbBqHL6N5rMGOdsUi9rfA9: the identity provider accepts the stored client secret
client 8BhnTaEg83o1lfhlwLSbBqHL6N5rMGOdsUi9rfA9 obtained a Bearer token, expires in 3600s
PYTHON SAMPLE VERIFIED
External Secrets (deploy/phase2/verify-external-secrets.sh)
==> stores
PASS team-bravo SecretStore Ready
PASS team-alpha SecretStore Ready
==> owner sync (team-bravo)
PASS ExternalSecret team-bravo/report-exporter-oauth synced
keys: authorization_endpoint, client_id, client_secret, client_type, grant_types, idp, issuer,
jwks_uri, managed_by, owner_group, scopes, state, token_endpoint, userinfo_endpoint
state: active client_id: 8BhnTaEg83o1lfhlwLSbBqHL6N5rMGOdsUi9rfA9 client_secret: present (64 chars)
PASS Authentik accepts the synced secret
==> cross-team attempt (team-alpha -> team-bravo path)
reason: SecretSyncedError
message: could not get secret data from provider
PASS team-alpha cannot sync team-bravo's credentials; no Secret created
EXTERNAL SECRETS VERIFIED
What the vault recorded
External Secrets' error message is generic. The vault audit log is not:
| Time (UTC) | Identity | Policies | Request | Result |
|---|---|---|---|---|
22:19:44 | kubernetes-team-bravo-courier-secrets | default, team-bravo | read kv/data/teams/team-bravo/oauth-clients/team-bravo-report-exporter | ok |
22:19:45 | kubernetes-team-alpha-courier-secrets | default, team-alpha | read kv/data/teams/team-bravo/oauth-clients/team-bravo-report-exporter | permission denied |
team-alpha's store signed in successfully as its own identity, then was refused on the path it did not own, on each retry.
Handling rules
- Read credentials at runtime. Never copy them into config files, container images, tickets or chat.
- Never log the object that holds them. The Python sample's
reprhides the secret; your own code must too. - Use a workload's own identity, never a person's vault token, for anything automated.
- Check
stateisactive. The Python sample does this; with External Secrets, the value is in thestatekey. - If you use External Secrets, restrict who can read Secrets in your namespace.