Skip to main content

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).

FieldWhat it is
hintWhat the value is allowed to say about itself
hashA 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.

ValueHint
sk-live-9f2a7c4e18b6d05sk-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 holdsSees
VariableRead onlyvalue is empty; sensitive, hint and hash are present
VariableRead + VariableReadSecretsThe 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​

EndpointBehaviour without VariableReadSecrets
GET /variablesSensitive 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 /variablevaluesSensitive entries replaced by their hint, checked after resolution and substitution
GET /variablevalues/previewWinning 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.

POST /variables — a sensitive variable
{
"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.