Enrollment tokens

TOTP enrollment token flow for the LinuxGuard agent in containers — LINUXGUARD_ENROLL_TOKEN, valueFrom.secretKeyRef pattern, token-hash tag for renewal tracking.

A LinuxGuard agent in ephemeral mode enrols once at startup via a short-lived TOTP enrolment token. The token is a single-use credential that the backend's /agent/enroll handler exchanges for an mTLS cert chain. This page documents how to supply the token securely, why the agent immediately unsets the env var at startup, and the valueFrom.secretKeyRef pattern that is the only correct Kubernetes injection path.

Security Note: NEVER set LINUXGUARD_ENROLL_TOKEN to a literal value in YAML, ConfigMaps, command-line arguments, or anything else that ends up in plain text in your source repository or shell history. The only safe injection paths are: a Kubernetes Secret referenced via valueFrom.secretKeyRef, an environment variable populated by a secrets manager at container start, or a Docker -e flag whose value comes from a shell variable sourced at runtime from a secret store.

Token format

The token is a base32-encoded string of 24-32 characters, typically formatted as four groups separated by dashes (e.g., ABCD-EFGH-IJKL-MNOP-QRST-UVWX). Generation is done in the LinuxGuard console (Identity → Enrollment Tokens → Create) — the token is shown once at creation time and never again. If you lose it, generate a new one.

Property
Value

Length

24-32 characters

Character set

Base32 alphabet (A-Z, 2-7), plus optional dashes for readability

Time-to-live

Configured per-tenant; typical 5-15 minutes

Use count

Single-use per workload identity — see § Re-use anti-pattern

Console path for creation

Identity → Enrollment Tokens → Create

Required flags / env vars

Three values must be supplied for the TOTP enrolment path to succeed:

Variable
Purpose
Source

LINUXGUARD_ENROLL_TOKEN (or --enroll-token)

The TOTP token itself

valueFrom.secretKeyRef (K8s) or shell variable sourced from a secret store (Docker)

LINUXGUARD_TENANT_ID (or --tenant-id)

Tenant the agent enrols into. Required on the TOTP path.

valueFrom.secretKeyRef (K8s) or shell variable (Docker); the tenant ID is not secret-sensitive but is typically stored alongside the token for operational convenience

Workload identity inputs

EITHER LINUXGUARD_NODE_NAME + LINUXGUARD_POD_UID from the Downward API, OR --workload-id <hex>

The backend's /agent/enroll handler rejects the enrolment with 400 "tenantId required for TOTP enrollment" when the request body's TenantID is empty AND EnrollCode is set. Long-lived API-key enrollments do NOT require the tenant ID (they carry it server-side), but the TOTP path always does.

Why the agent unsets the env var at startup

The agent reads LINUXGUARD_ENROLL_TOKEN once at the very top of the start action, captures the value, and immediately calls os.Unsetenv("LINUXGUARD_ENROLL_TOKEN") BEFORE any goroutine forks. This special handling addresses a Linux process-introspection leak:

On Linux, an unprivileged process running as the same UID as the agent can read /proc/<linuxguard-pid>/environ for the lifetime of the agent process and observe every environment variable the agent inherited at exec time. If LINUXGUARD_ENROLL_TOKEN remained in the agent's environ block, a hostile co-tenant or a misconfigured monitoring agent could read the raw token and (within its TTL) self-enrol a rogue identity into the same tenant.

os.Unsetenv updates the Go runtime's internal environ() slice and rewrites the same memory the kernel exposes via /proc/<pid>/environ. Subsequent reads of that pseudo-file return the redacted set. Empirically the window in which /proc/<linuxguard-pid>/environ contains the token is approximately the latency between execve and os.Unsetenv — measured in milliseconds, not minutes.

The token is consumed once for the enrol POST and never needed again. The resulting mTLS cert chain is held in memory (or mirrored to /run/linuxguard/tls/ when --tls-cache is set — see Ephemeral mode § TLS cache restart semantics). The early unset does not impede operation.

Security Note: The unset DOES NOT clear the inherited environ block from kernel memory if a child process was already forked before the unset call. Avoid CMD entrypoints that spawn shell wrappers, init scripts, or other intermediary processes before the agent binary executes. The published distroless image's ENTRYPOINT ["/usr/local/bin/linuxguard-agent"] execs the agent directly with no shell — preserve this property in any downstream layer.

Kubernetes: valueFrom.secretKeyRef pattern

The ONLY correct Kubernetes injection path is valueFrom.secretKeyRef. The token lives in a Secret, the Pod spec references the Secret key, and the kubelet populates the env var at container start.

Step 1: Create the Secret

Or declaratively (suitable for GitOps when the value is sourced from a secrets manager via kustomize / helm / external-secrets-operator, NEVER from a checked-in literal):

Step 2: Reference the Secret in the Pod spec

The kubelet reads the Secret from etcd at Pod admission time and populates the env vars in the container's environ block. The values flow through the same os.Environ path the agent reads at startup, and the same os.Unsetenv scrub applies to LINUXGUARD_ENROLL_TOKEN once it has been consumed.

Step 3: Confirm the Secret is NOT exposed as a literal value anywhere

Audit the Pod spec for the anti-pattern below:

Even if the manifest lives in a private repository, this leaks the token to:

  • Anyone with read access to the repository (including CI build logs that echo the manifest).

  • kubectl get pod <name> -o yaml output, which is visible to anyone with pods/get RBAC in the namespace.

  • Cluster audit logs, which may be retained for years.

The valueFrom.secretKeyRef pattern keeps the token out of all three surfaces. The Secret object itself can be additionally protected via Kubernetes --encryption-provider-config at the etcd layer.

Docker: shell-sourced env var

For Docker / Podman deployments where Kubernetes Secret machinery is not available, the equivalent pattern sources the token from a shell variable that is populated at runtime from a secret store (e.g., HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, 1Password CLI):

The shell variable assignment is ephemeral — it lives only in the current shell process and is GC'd when the script exits. Do NOT export the variable into the wider shell environment; the -e flag accepts non-exported variables in the same expansion pass.

Avoid:

  • -e LINUXGUARD_ENROLL_TOKEN=ABCD-EFGH-... with a literal value (shell history leak).

  • Docker's environment-file flag pointing at a linuxguard.env written to disk in plain text (filesystem persistence leak).

  • docker exec invocations that pass the token as a command-line argument (cmdline leak).

Token-hash tag for renewal tracking

When --tls-cache is set, the agent writes a .tag sidecar file in the cache directory containing:

This tag value is the agent's mechanism for detecting token rotation across restarts. On every start with --tls-cache:

  1. The agent computes the current .tag value from the (just-read) env-var token + the derived workload-id.

  2. The agent reads the existing .tag file from the cache.

  3. If they match — the token has not been rotated since the last successful enrolment, and the cached cert chain is reused without an enrol call.

  4. If they diverge — either the token has been rotated, or the workload-id has changed (e.g., new Pod UID). The cache directory is deleted and a fresh enrolment runs under the new token.

The tag value contains a hashed token, not the raw token. An attacker reading /run/linuxguard/tls/.tag learns only that some hash of some token + workload-id exists; they cannot recover the token from the tag.

Rotation cadence

Token rotation cadence is a tenant policy choice. Common shapes:

  • Per-deployment roll — rotate the Secret before each DaemonSet roll so every new pod enrols under a fresh token. The token-hash tag invalidates the cache automatically; no manual cache clear required.

  • Per-hour rotation — rotate the Secret hourly via a CronJob that pulls a fresh token from the LinuxGuard API and updates the Kubernetes Secret. Existing pods continue using their cached cert chain (the agent reads the env var only at startup; mid-process rotation has no effect on a running agent). New pods enrol under the freshly-rotated token.

  • On-demand rotation — rotate when a token is suspected of compromise. Delete the old Secret, create a new one, and roll the DaemonSet. The cache-tag mismatch on the rolled pods triggers fresh enrolment.

The agent does NOT poll for token rotation while running. The token is consumed only at startup; rotation takes effect on the next restart.

Re-use anti-pattern

Important: Each pod must receive a FRESH TOTP token. Re-using the same token across multiple pods in a DaemonSet produces partial enrolment with silent gaps.

The backend's /agent/enroll handler treats each token as single-use per workload identity. The first pod that POSTs the enrolment with a given token + workload-id succeeds and receives the cert chain. Subsequent POSTs from other pods with the same token + workload-id fall into one of three failure modes:

  1. Same workload-id, replay — backend returns the cached enrolment response idempotently. The replayed pod gets the same cert chain. This works correctly only when the workload-id matches (the Downward API binding guarantees per-Pod uniqueness; see Downward API § Workload identifier derivation).

  2. Different workload-id, same token — backend returns 409 conflict because the token was already consumed by a different identity. The pod fails to enrol and either exits or retries; either way it does not produce telemetry.

  3. Token expired — backend returns 400 after the TOTP TTL has elapsed. The pod fails to enrol. Pods that pull a fresh-but-expired token from an out-of-date Secret experience this; rotate the token before the TTL window closes.

Correct rotation pattern:

The Kubernetes Secret is rotated externally (by an enrolment service, secrets manager, or operator-driven process) so that each new pod sees a new value. Common shapes:

  • An external-secrets-operator that syncs the LinuxGuard /api/tokens/new response into the Kubernetes Secret on a schedule.

  • A pre-roll CronJob that rotates the Secret before invoking kubectl rollout restart.

  • A controller pattern that watches DaemonSet events and rotates the Secret on each new pod start.

The shape that is INCORRECT: a single token value baked into the Secret at cluster bootstrap and never rotated. The first DaemonSet rollout works; every subsequent pod (on node addition, on Pod eviction, on container restart-without-cache) fails to enrol.

--enroll-token flag vs LINUXGUARD_ENROLL_TOKEN env var

The start command accepts the token via either path. The env var is preferred:

Path
Recommended
Why

LINUXGUARD_ENROLL_TOKEN env var

Yes

The agent reads the env var, captures the value, immediately calls os.Unsetenv to scrub /proc/<pid>/environ. Token does not appear in the process command line, shell history, or kubectl describe pod event output.

--enroll-token <value> flag

No

The token appears in /proc/<pid>/cmdline, which is world-readable by default (any local UID can read it). Also appears in shell history and in container-runtime audit logs that record the command.

The --enroll-token flag exists for compatibility (and for one-shot interactive testing where the operator types the token at a controlled shell), but production deployments MUST use the env-var path.

Security context

The enrolment-token flow runs the agent under the same security context as any other ephemeral deployment — see Ephemeral mode § Security context. The token-specific surface adds the env-var scrub described above and the secretKeyRef Kubernetes pattern; neither modifies the Pod-level security context.

Host paths

The enrolment-token flow requires no additional host-path mounts beyond those documented in the per-orchestrator spoke. The token is consumed at startup and the resulting cert chain is held in memory (or in the /run/linuxguard/ tmpfs when --tls-cache is active) — no node-level filesystem access is involved in the token flow itself.

Pod Security Standard compatibility

PSS profile

Compatible with valueFrom.secretKeyRef?

Notes

privileged

Yes

No PSS-level constraints.

baseline

Yes

Secret-backed env vars are permitted under baseline.

restricted

Yes

Secret-backed env vars are permitted under restricted. The token-flow surface itself is PSS-neutral — the agent's other capability requirements are what classify the Pod as privileged.

The enrolment-token flow itself does not influence the Pod's PSS classification. It can be combined with any Pod-level security context the orchestrator permits.

RBAC

The enrolment-token flow requires that the Pod's ServiceAccount can read the linuxguard-enroll Secret in its own namespace. The default behavior in Kubernetes (RBAC enabled) is that a Pod's ServiceAccount has access to Secrets explicitly referenced in its own Pod spec via the kubelet's Secret-reading credentials — NO explicit Role or RoleBinding is required for the Pod itself.

If your cluster runs additional admission policies that restrict Secret reads (e.g., OPA Gatekeeper, Kyverno), the rules MUST allow the linuxguard-agent ServiceAccount to read the linuxguard-enroll Secret. The minimal Role + RoleBinding for explicit access:

This is required ONLY when an admission policy enforces explicit Secret-read permission. The default Kubernetes RBAC behavior does not require it.

Verification

After applying a manifest with valueFrom.secretKeyRef, verify the token is being delivered to the container without appearing in any visible surface:

A successful enrolment produces a log line containing enrol or heartbeat within 20 seconds of pod start. If the agent logs enrolment failed: 400 tenantId required for TOTP enrollment, the LINUXGUARD_TENANT_ID env var is missing or empty. If the agent logs 409 conflict, the token has already been consumed by a different workload-id — rotate the Secret.


Next Step: docker-compose →

Related: Container deployment hub | Ephemeral mode | Downward API integration | Kubernetes DaemonSet | Environment variables

Last updated

Was this helpful?