Application teams Sample samples/python Example deploy/phase2/examples

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 areUseSigns in withWhere the secret ends up
A person on the team, looking or testingVault CLI or UIYour normal login (OIDC)Your terminal, briefly
A job or service outside KubernetesPython sampleA JWT from the identity providerprocess memory only
A pod that can call the vault itselfPython sample, Kubernetes modeThe pod's service accountprocess memory only
A pod that expects environment variables or filesExternal SecretsThe team's courier-secrets service accounta Secret in your namespace
The trade-off

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

Variabletoken modejwt modekubernetes mode
COURIER_VAULT_AUTHtoken (default)jwtkubernetes
VAULT_ADDRrequired, e.g. https://vault.sororlab.dev or http://vault-active.vault.svc:8200 in-cluster
IdentityVAULT_TOKEN or ~/.vault-tokenCOURIER_JWT or COURIER_JWT_FILEpod service account token
VAULT_ROLEunuseddefault machineyour workload's Kubernetes auth role

What it checks for you

SituationWhat you get
Identity not in the owning groupPermissionError: not allowed to read kv/teams/team-bravo/…: is this identity in IdP group 'team-bravo'?
Request not merged, or retiredLookupError: nothing at kv/teams/…: is the request merged and Ready?
Courier still deliveringCredentialsNotReady: … is 'pending'; wait for the OAuthClient to be Ready
Public clientclient_secret is None; client_credentials_token refuses
Kubernetes mode needs a role for your workload

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>:

ObjectPurpose
ServiceAccount courier-secretsThe identity External Secrets uses to sign in to the vault; no pod mounts it
SecretStore courier-vaultSigns 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, ...
WhenWhat happens to the Secret
Request changes (scopes, groups)Updated at the next refresh; the secret value is unchanged
Secret re-issuedUpdated at the next refresh (up to refreshInterval); restart pods that read it only at start-up
Request retiredWith deletionPolicy: Delete, removed once the vault entry is gone
ExternalSecret points at another team's pathNever 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)IdentityPoliciesRequestResult
22:19:44kubernetes-team-bravo-courier-secretsdefault, team-bravoread kv/data/teams/team-bravo/oauth-clients/team-bravo-report-exporterok
22:19:45kubernetes-team-alpha-courier-secretsdefault, team-alpharead kv/data/teams/team-bravo/oauth-clients/team-bravo-report-exporterpermission 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 repr hides the secret; your own code must too.
  • Use a workload's own identity, never a person's vault token, for anything automated.
  • Check state is active. The Python sample does this; with External Secrets, the value is in the state key.
  • If you use External Secrets, restrict who can read Secrets in your namespace.