Skip to content

Webhook Secrets

A Bitbucket webhook can carry two credentials, and bb treats both as things it holds rather than things it prints.

  • The shared secret signs every delivery. Bitbucket sends it as X-Hub-Signature: sha256=… so the receiving endpoint can prove the request came from Bitbucket and not from someone who learned the URL.
  • The endpoint credentials are a username and password bb never sees the second half of. Bitbucket sends them as an Authorization: Basic … header on every delivery, so the endpoint can authenticate the caller.

What bb publishes, and what it does not

Bitbucket returns the shared secret in plaintext on every read of a webhook. bb does not pass it on. What the model publishes instead is whether one is configured:

$ bb webhook get 42 --json
{
  "data": {
    "webhook": {
      "id": 42,
      "name": "ci",
      "url": "https://ci.example.com/hooks/bitbucket",
      "active": true,
      "events": ["repo:refs_changed"],
      "sslVerificationRequired": true,
      "scopeType": "repository",
      "secretConfigured": true,
      "credentialsUsername": "bitbucket"
    }
  }
}

Three details are worth knowing:

  • secretConfigured is always present. secret is not — it appears only with --reveal-secret.
  • sslVerificationRequired is absent when the server did not report it, rather than false. Absent and "TLS verification is off" are different answers, and this is the field an audit reads.
  • There is no field for the endpoint password. Bitbucket never returns it, so bb has nothing to publish. credentialsUsername is the only half that comes back.

bb webhook test publishes the delivery record Bitbucket produced, and that record contains the request headers — including Authorization. bb replaces the value with <redacted>. Base64 is not encryption: the header carries the endpoint password, and under --json that record is the machine contract.

Recovering a secret on purpose

--reveal-secret prints what would otherwise be redacted. It exists so that losing a secret does not mean going to the database, and it has to be typed:

bb webhook get 42 --json --reveal-secret          # the shared secret
bb webhook test 42 --json --reveal-secret         # the Authorization header

Both write a warning to stderr saying a credential went through stdout, so a log that contains one also contains the note that it does. The warning never repeats the value.

Setting a secret

No flag takes a secret as its value (ADR-047): a flag value lands in the process argument list, which is world-readable on Linux, and in shell history. Two routes are open instead.

From the environment — the automation path

Variable Sets
BB_WEBHOOK_SECRET the shared secret
BB_WEBHOOK_PASSWORD the endpoint password
export BB_WEBHOOK_SECRET="$(vault read -field=value secret/bitbucket/webhook)"
bb webhook create ci https://ci.example.com/hooks/bitbucket \
  --event repo:refs_changed --ssl-verification=true

This is the form to reach for in a pipeline: the secret comes from wherever the runner keeps secrets and never appears in a command line.

An empty variable counts as unset, so a typo in a variable name two files away cannot quietly configure an empty credential.

From stdin — one secret at a time

printf '%s' "$SECRET" | bb webhook create ci https://ci.example.com/hooks/bitbucket --secret-stdin
printf '%s' "$PASSWORD" | bb webhook update 42 --credentials-username bitbucket --credentials-password-stdin

--secret-stdin and --credentials-password-stdin cannot both be given: there is one stdin, and bb refuses rather than guessing which secret arrived. Put the other one in its environment variable.

A secret piped on stdin may not contain spaces, tabs or newlines. A value that arrived with a stray space is far more likely to be a piping mistake than a real credential, and the failure it causes shows up much later, at the receiving endpoint. If your secret genuinely contains whitespace, set it through the environment instead.

Precedence

stdin wins over the environment. Piping is the explicit act; a variable can come from a shell profile the caller has forgotten.

A flag typed on this invocation outranks the environment entirely, which is what makes --no-secret usable on a host that exports BB_WEBHOOK_SECRET for everything:

bb webhook update 42 --no-secret          # removes it, variable or no variable
bb webhook update 42 --no-credentials     # removes the endpoint credentials

Combining --no-secret with --secret-stdin is refused. One of the two has to win and neither should.

What an update leaves alone

Bitbucket's update endpoint replaces the webhook rather than patching it: a field that does not arrive is cleared. bb reads the webhook first and sends back what you did not mention, so --name changes the name and nothing else.

This matters most for the two credentials:

  • The shared secret survives because Bitbucket returns it and bb sends it straight back. Sending an update without a configuration object clears it.
  • The endpoint password survives for a different reason. bb cannot send it back — Bitbucket never returned it — so it sends the credentials object with the username alone, and Bitbucket keeps the password it already had. This is verified against a live instance by observing the Authorization header on a real delivery before and after an update, because no API response can show it.

Dry runs name the variable, not the value

A dry run is written to stdout, is what an operator reads before applying, and is what gets pasted into a ticket when it looks wrong. It says where the secret will come from and never what it is:

$ export BB_WEBHOOK_SECRET=…
$ bb webhook create ci https://ci.example.com/hook --dry-run --json
{
  "preview": {
    "tier": "preconditions-checked",
    "effects": [
      {
        "action": "create",
        "target": {
          "repository": "PROJ/repo",
          "name": "ci",
          "url": "https://ci.example.com/hook",
          "secret": "will be set from $BB_WEBHOOK_SECRET"
        },
        "outcome": "would-apply",
        "reasons": ["webhook will be created"]
      }
    ]
  },
  "meta": { "command": "webhook create", "bbVersion": "…" }
}

Naming the variable is also the more useful answer: the mistake a preview is there to catch is reading the wrong one. Removing a credential shows as "will be removed".

Where the fields can be set

The same flags are registered wherever a webhook is configured, so the answer to "can I set the shared secret here" does not depend on which command you reached for:

Command create update
bb webhook yes yes
bb project webhook yes yes
bb repo settings workflow webhooks yes no update subcommand