Sensitive values
A variable can be marked sensitive — not any one of its values individually. Doing so changes three
things for every value the variable holds: what is stored alongside each one, who is shown it, and what they
are shown instead.
A variable's type also constrains this: a Boolean variable can never be marked sensitive, since its values are
shown as a tick box rather than text and there is nothing to mask. Every other type can be. See
Library sets and variables.
Sensitivity only ever increases
Once a variable is marked sensitive, it cannot be made not sensitive again — PUT /variables/{id} refuses a
request that would set sensitive back to false on a variable that is already true, with 409 Conflict.
Going the other way, from not sensitive to sensitive, is always allowed.
What is stored
Marking a variable sensitive derives two extra fields per value, and they are written in exactly one place —
SensitiveValueRules.Apply — so a value can never carry a hint for something it no longer is. The text a hint
or hash is computed from is whichever field the variable's type actually stores a value in - value for every
type except Number, where it is the number itself written out as text (so 8080 hints and hashes the same
way "8080" would).
| Field | What it is |
|---|---|
hint | What the value is allowed to say about itself |
hash | A SHA-256 of the value, salted with the organization ID |
Every value of a variable that is not sensitive keeps neither.
The hint
The hint exists so that whoever set a secret can tell which one it is without being shown it.
- Longer than 15 characters (ignoring whitespace) — the first 4 characters.
- 15 characters or fewer — a run of asterisks, one per non-whitespace character.
Whitespace is neither counted nor shown. A value padded with spaces is not treated as the long value it is not, and a hint never opens with characters that say nothing.
| Value | Hint |
|---|---|
sk-live-9f2a7c4e18b6d05 | sk-l |
hunter2 | ******* |
short | ***** |
The hash
The hash lets a value be recognised without being read — telling whether two variables hold the same secret, or whether an incoming value is actually a change. It is salted with the organization ID, so the same secret held by two organizations does not hash to the same thing.
It is computed over the value exactly as given, whitespace included: two values differing only by a space are two different secrets, whatever their hints have in common.
Who is shown what
One permission decides it, and one helper — CanReadSecrets — asks the question, so a secret is withheld by the
same rule wherever it is read from.
VariableReadSecrets — which itself depends on VariableRead, because reading a secret says nothing without being able to read the variable holding it.
| Caller holds | Sees |
|---|---|
VariableRead only | value is empty; sensitive, hint and hash are present |
VariableRead + VariableReadSecrets | The value itself |
The hint and hash go out either way. They are what a withheld value has instead of itself, and without them a caller could not tell which secret they were being refused.
Every endpoint that can return a value
| Endpoint | Behaviour without VariableReadSecrets |
|---|---|
GET /variables | Sensitive values blank |
GET /variables/{id} | Sensitive values blank |
PUT /variables/{id} | The response reflecting the write has them blank too |
GET /variablesets/{id} | Sensitive values blank throughout the set |
PUT /variablesets/{id} | As above |
GET /variablevalues | Sensitive entries replaced by their hint, checked after resolution and substitution |
GET /variablevalues/preview | Winning value returned with an empty value and its hint |
Both resolve and substitute first, then withhold. That ordering is what lets substitution see a secret's real text
when composing another variable's value — see Value resolution — while a caller without
VariableReadSecrets still never receives it: whichever entries end up sensitive, including ones that only became
sensitive by embedding a secret, come back as their hint (/variablevalues) or an empty value beside the hint
(/variablevalues/preview).
Writing secrets
Writing is a different question from reading, and needs no secret permission at all. VariableCreate and
VariableUpdate are enough to set a sensitive value.
The same asymmetry applies to import: BatchProcessingImport covers writing values, sensitive ones included, and
VariableReadSecrets is deliberately absent from its dependencies. An import writes secrets and is never
shown one back.
Export is the mirror case, and its check is conditional rather than absent: GET /export's
secretExportOption=Value hands over a sensitive value's actual text and is refused with 403 without
VariableReadSecrets. The other three options - Hint, Hash, Blank - need nothing beyond
BatchProcessingExport, since none of them hand over the secret itself. See
Exporting a library.
{
"name": "ApiToken",
"variableSetId": 1,
"sensitive": true,
"values": [
{ "value": "sk-live-9f2a7c4e18b6d05", "tags": ["Environment/Production"] }
]
}
What this is not
Values are stored in the database as given. Marking one sensitive controls who is shown it and what they see instead — it is not encryption at rest, and it does not stop anyone with database access from reading it.
The same caveat applies to identity provider client secrets held on an organization record: they are returned by
GET /organizations/{id} to holders of GlobalOrganizationRead, and by the anonymous
GET /organizations/{id}/signin. Encrypting those at rest is a known outstanding item, and their present
protection is that the account API is not reachable from outside the cluster.