Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

312 lines · 32.1 KBRaw History
1---
2title: Security overview
3description: Diffinery's security model end to end: sign-in, sessions, tokens, agent guardrails, branch and push protection, secrets, Actions, webhooks and auditing.
4---
5
6This page explains how Diffinery protects accounts, code and secrets, from how people sign in to how workflow secrets are encrypted and how every sensitive action is recorded. Read it to understand the model as a whole; each section links to a page with the details.
7
8## At a glance
9
10| Area | What Diffinery does |
11|---|---|
12| Sign-in | Passkeys only (WebAuthn). No passwords. |
13| Browser sessions | Random session cookie, stored hashed, `HttpOnly`, `Secure`, `SameSite=Lax`, 30 days sliding |
14| Cross-site requests | Double-submit CSRF token on every form, plus refusal of cross-site state-changing requests |
15| Headers | HSTS, nonce-based Content Security Policy, `X-Frame-Options: DENY`, `nosniff` |
16| Images in Markdown | Images from other sites are served through Diffinery's own image proxy, so those sites never see who views a page |
17| Anonymous access | Read-only: anonymous callers can read public repositories but every change gets `401` |
18| Machines and agents | Tokens (`dfy_...`), stored hashed, with scopes, repository restrictions and expiry; `read` tokens never write |
19| Agents | Accounts owned by a person or organization, with server-enforced limits |
20| Branches | Branch protection rules, enforced for git, the API, MCP and the web UI |
21| Required checks | Checks reported by agents never satisfy a required check; a check can be bound to one reporter |
22| Pushes and commits | Credential scanning on every push and on every commit made without Git (web editor, REST API, MCP); blocked writes leave nothing behind |
23| File names | Writes through the API, MCP and the web editor refuse `.git`, `..` and other unsafe tree entry names |
24| Archived repositories | Every write refused, except unarchiving, deleting, watching and linking packages |
25| Actions secrets | Envelope encryption with a per-repository, per-environment or per-organization key derived from `MASTER_KEY` |
26| Workflow logs | Secret values are replaced with `***` |
27| Workflow credentials | A job token valid for one job, limited by the workflow's `permissions:` (read-only by default for new repositories), and short-lived OIDC id tokens |
28| Webhooks | HTTPS only, signed with HMAC-SHA256, failed deliveries retried |
29| Accountability | Append-only audit log and security emails |
30| Isolation | One Durable Object per repository |
31
32## Signing in with passkeys
33
34People sign in to the web UI with a passkey: a WebAuthn credential kept by their device, password manager or security key. Diffinery has no passwords to guess, reuse or phish.
35
36- Passkeys are bound to the site's hostname (`diffinery.com`), so a look-alike site cannot use them.
37- Diffinery requires user verification (a fingerprint, face or device PIN) and discoverable credentials, and does not ask for attestation.
38- Each sign-in uses a fresh challenge that expires after 5 minutes, can be used once, and is tied to the browser that requested it by a `__Host-dfy_webauthn` cookie (`HttpOnly`, `Secure`, `SameSite=Strict`).
39- New people create their first passkey from an invite link, which expires after 14 days and works once.
40- If someone loses all their passkeys, a site admin can create a one-time sign-in link that lets them add a new passkey. It expires after 24 hours and works once. An instance can also offer email recovery through Cloudflare Access at `/auth/access`; Access guards only that path.
41- Agents cannot sign in to the web UI. Only people get browser sessions.
42
43Manage your passkeys under **Settings**, **Passkeys and sessions**. Removing a passkey also signs out every other browser that signed in with it. See [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in).
44
45## Sessions
46
47After a passkey sign-in, Diffinery sets a session cookie:
48
49| Property | Value |
50|---|---|
51| Name | `__Host-dfy_session` (the `__Host-` prefix pins it to this origin, `Path=/`, no `Domain`) |
52| Flags | `HttpOnly`, `Secure`, `SameSite=Lax` |
53| Lifetime | 30 days, extended by another 30 days on the first use of each day, and never more than 90 days after the sign-in |
54| Storage | Only a SHA-256 hash of the session token is stored |
55
56Signing in again replaces the browser's previous session. **Settings**, **Passkeys and sessions** lists your **Signed-in browsers**. **Sign out** next to one ends that session, and **Sign out everywhere else** ends every session except the current one. A disabled account cannot use its sessions or tokens.
57
58### Confirming it's you
59
60Some actions need a sign-in from the last 2 hours, much like GitHub's sudo mode:
61- adding or removing a passkey;
62- creating a personal access token, an agent, or an agent token;
63- changing your username;
64- issuing a sign-in link (site administrators);
65- deleting or transferring a repository, or purging its unreachable objects;
66- deleting an organization;
67- exporting your account, downloading the export, or deleting your account;
68- deleting someone's account, or making someone a site administrator or removing them (site administrators).
69
70When a browser's sign-in is older than that, Diffinery asks you to **Confirm it's you** with your passkey (or by email recovery, where that is set up), then takes you back to the page. Then submit the form again. The same calls through the API with a browser session get `403 Confirm it's you: sign in with your passkey again to do this.`, with a `confirm` URL in the body. So a session left open on a shared computer, or a stolen session cookie, can't add its own passkey, mint a lasting token or delete your work. Tokens aren't affected: their scopes bound what they can do.
71
72## Protection against cross-site requests
73
74Browser sessions ride on cookies, so Diffinery applies two independent defenses against cross-site request forgery.
75
76**Cross-site refusal.** Every request that changes state (any method other than `GET`, `HEAD` and `OPTIONS`) and carries no `Authorization` credential is checked first:
77
78- If the browser sends `Sec-Fetch-Site` and its value is not `same-origin` or `none`, the request is refused.
79- If there is no `Sec-Fetch-Site` header but there is an `Origin` header that differs from the site's own origin, the request is refused.
80
81The response is `403` with `{"message": "cross-site request refused"}`. Requests authenticated with a token (git, the CLI, MCP clients, runners) are exempt because they carry no ambient credentials.
82
83**Double-submit CSRF token.** Every page sets a random `dfy_csrf` cookie (`HttpOnly`, `Secure`, `SameSite=Strict`, 30 days), and every form carries the same value in a hidden `_csrf` field. A form submission is accepted only when:
84
85- its `Origin`, if present, is the site's own origin (otherwise `403` with `cross-origin form submission rejected`),
86- it is a form submission (`application/x-www-form-urlencoded` or `multipart/form-data`; otherwise `415` with `expected a form submission`), and
87- the field matches the cookie, compared in constant time (otherwise `403` with `your session token expired or is missing; reload the page and try again`).
88
89The passkey endpoints take JSON, which forces a CORS preflight that other sites cannot pass.
90
91## Security headers and content security policy
92
93Every response carries:
94
95| Header | Value |
96|---|---|
97| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` |
98| `X-Content-Type-Options` | `nosniff` |
99| `Referrer-Policy` | `strict-origin-when-cross-origin` |
100| `X-Frame-Options` | `DENY` (unless a response sets its own) |
101| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` |
102
103Web pages also get `Cache-Control: private, no-store` and a Content Security Policy with a new random nonce for each response:
104
105```text
106default-src 'self'; script-src 'nonce-...'; style-src 'self' 'unsafe-inline';
107img-src 'self' data: blob:; connect-src 'self' wss://diffinery.com ws://diffinery.com;
108font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'
109```
110
111Only scripts that carry the page's nonce run, so injected markup cannot execute scripts. Raw file views and downloadable logs are served with `Content-Security-Policy: sandbox`, so a file that contains HTML cannot run scripts in Diffinery's origin.
112
113Pages load images only from Diffinery itself. Images from other sites in Markdown come through Diffinery's image proxy (see [Images from other sites](#images-from-other-sites)).
114
115## Images from other sites
116
117An image in Markdown (an issue, pull request, comment, review, README, Markdown file, release notes, job summary or agent task) that points at another site would tell that site the IP address, browser and time of everyone who views the page, even in a private repository. So Diffinery fetches those images itself and serves them from its own address, as GitHub does:
118
119- The page shows the image from `/-/camo/<signature>/<encoded URL>`. Only URLs Diffinery signed while rendering are fetched, so the proxy can't be used to fetch anything else.
120- Diffinery fetches the image over HTTPS without your cookies, IP address or browser details. An `http://` image is fetched over `https://`, as browsers already do on an HTTPS page.
121- Only public hosts are fetched: never Diffinery itself, `localhost` or private, link-local or internal addresses, also after a redirect (at most 4 are followed).
122- The answer must be a PNG, JPEG, GIF, WebP, AVIF or SVG image of at most 10 MB, within 10 seconds. SVG images are served with a sandboxing Content Security Policy, so scripts in them can't run.
123- Images are cached for as long as the other site allows (between 5 minutes and 7 days; a day if it doesn't say), so the other site sees only an occasional request from Diffinery. An image that can't be fetched shows as broken.
124
125Links in Markdown still go to their original addresses, and images from the repository itself (relative paths) or from Diffinery load directly.
126
127## Tokens and scopes
128
129Everything that is not a browser (git, the [CLI](/docs/reference/cli), scripts, MCP clients, agents) authenticates with a token in the `Authorization` header (`Bearer`, `token` or HTTP Basic with the token as the password).
130
131| Property | Detail |
132|---|---|
133| Format | `dfy_` followed by 40 random characters |
134| Storage | Only a SHA-256 hash is stored; the token is shown once, at creation |
135| Expiry | 1 to 366 days; 90 by default (30 for agent tokens created in the web UI) |
136| Scope | `read`, `write` or `admin` |
137| Repositories | Optionally restricted to a list of repositories |
138| Revocation | Immediate; disabling an account revokes all of its tokens |
139
140A token never grants more than its account has. The scope caps the account's role on each repository:
141
142| Scope | Highest role it allows |
143|---|---|
144| `read` | read: clone and view |
145| `write` | maintain: push, issues, pull requests, runs, and moving tags |
146| `admin` | admin: everything the account can do, including settings |
147
148A `read` token can't change anything: every repository request other than `GET` or `HEAD` gets `403 this token needs the write scope`, even for changes the `read` role allows, such as opening an issue or commenting. `git fetch` and Git LFS downloads still work with it.
149
150The scope is checked only after Diffinery has found a repository the caller can see, so a `read` token's write to a private repository it can't see gets `404 repository not found`, not a scope error that would reveal the repository.
151
152Account-level changes check the scope directly and refuse tokens restricted to repositories (`403 this token is limited to repositories and can't change account settings`). Creating repositories needs `write`; creating organizations, inviting people, managing organization secrets, disabling agents and revoking tokens need `admin`. No token can create a token or an agent: those need a browser session, so a leaked token can't mint another one. Job and sandbox tokens can't start sandboxes either, and a sandbox's credentials last 24 hours at most, and a new token can only be restricted to repositories the account can see (any other name gets `422 unknown repository <name>`). Scopes sent to the API are checked: an empty list or an unknown name is refused with `422 scopes must be a non-empty list of: read, write, admin`, and leaving them out gives `["write"]`. Every new token triggers a security email, and for tokens valid longer than 8 days you get a reminder about 7 days before the token expires. See [Tokens](/docs/accounts/tokens) and [Permissions](/docs/accounts/permissions).
153
154## Repository access
155
156Each request to a repository resolves the caller's role (read, triage, write, maintain or admin) from ownership, organization membership and collaborator grants, then applies the token's scope and repository restriction. Roles are computed on every request, so removing someone from an organization or a repository takes effect immediately, including for tokens they already have. Private repositories return `404 repository not found` to anyone without access, so their existence is not revealed. See [Permissions](/docs/accounts/permissions).
157
158### Anonymous callers only read
159
160Without a token or a session, a caller gets the `read` role on public repositories and nothing on private ones. A signed-out caller gets the same answer for a private repository as for one that doesn't exist: `401 authentication required` from Git, Git LFS and the web UI, and `404` from the REST API. As on GitHub, anonymous callers can only read: any request that changes a repository (any method other than `GET` or `HEAD`), such as opening an issue, commenting, reacting or watching, is refused with `401 authentication required`, even on a public repository. Anonymous `git clone`, `git fetch` and Git LFS downloads of public repositories keep working, although they use `POST`, because they only read. An anonymous `git push` gets `401` with a `WWW-Authenticate: Basic` challenge, even to a public repository, so git asks for (or its credential helper supplies) your token and retries. Unknown paths under `/api/` answer with a JSON `404` (`{"message": "Not Found", "documentation_url": "..."}`), not the web UI's error page.
161
162### Archived repositories
163
164An archived repository is read-only, for everyone including admins, as on GitHub:
165
166- `git push` is refused, and scheduled workflows (`on: schedule`) stop running.
167- The web UI shows code, issues and pull requests without any forms to change them. A conversation shows `This repository is archived; the conversation is read-only.`
168- Every write through the web UI, the REST API and MCP is refused with `403` and `repository is archived (read-only)`, whatever role the endpoint needs: issues, comments, reviews, pull requests, merges, labels, reactions, releases, statuses, check runs, deployments, git data, and the admin settings (collaborators, branch protection, webhooks, secrets and variables). Reading still works.
169- Only these still work: unarchiving, deleting the repository, watching or unwatching it, and linking a container image to it. See [Archive a repository](/docs/repositories/overview#archive-a-repository).
170
171A repository admin archives or unarchives a repository under **Settings**, **General**, with the **Archive** (or **Unarchive**) button and a confirmation. Both are recorded in the [audit log](/docs/security/audit-log) as `repo.archive` and `repo.unarchive`.
172
173## Agent guardrails
174
175Agents are accounts that act on behalf of a person or an organization. They use tokens only, and Diffinery enforces these limits on the server, for git, the REST API, MCP and every other path:
176
177- An agent's role is capped by its **Maximum role** (default write) and never exceeds its owner's role.
178- Agents cannot push to the default branch or to protected branches, unless a branch protection rule allows it (`agentsMayPush`).
179- Agents cannot merge pull requests into the default branch or protected branches, unless a rule allows it (`agentsMayMerge`).
180- Pull requests opened by agents need at least one approval from a person. Only people's approvals count toward required approvals; approvals from agents and workflow tokens never do.
181- Agents cannot change branch protection, delete repositories, create agents, or bypass push protection.
182- Whatever their role, agents cannot create, update or delete Actions secrets (repository, environment or organization), and cannot create, change or delete Actions variables (repository or environment). They get `403 agents cannot manage secrets` or `403 agents cannot manage variables`.
183- Agents never bypass branch protection when merging, even with the admin role.
184- Agents cannot create, change or delete [environments](/docs/actions/environments) or their deployment branch policies, and cannot set or delete environment secrets (`only repository admins (people, not agents) can manage environments`).
185- Agents cannot dismiss pull request reviews (`agents can't dismiss reviews`).
186- Commit statuses and check runs that agents report are shown, but never satisfy a required check in branch protection or in a merge queue. A deployment created without `required_contexts` also ignores contexts reported by agents. See [Checks and statuses](/docs/collaboration/checks-and-statuses).
187- Runs of code an agent wrote get no repository or organization secrets unless a repository admin who is a person allows it (`agent_runs_use_secrets`). Workflow runs started by agents receive an environment's secrets only when the environment is limited to protected branches. Tags created or moved by agents never match an environment's tag patterns.
188- A workflow run whose code came from an agent (an agent triggered it, pushed the branch last, or opened the pull request, every re-run of such a run, whoever starts it, and every `workflow_run` run that follows it) acts as an agent: its job token is held to these same rules, so it can't push or merge agent-written code into the default branch or a protected branch.
189- Commits that agents create through the API or MCP (including commits made with the git data API and the merge commit of `update-branch`) carry a `diffinery-agent` header naming the agent and who it acts for.
190- The audit log records who an agent acted for, and security emails about an agent (blocked pushes, new tokens) go to its owner.
191
192See [Agents](/docs/agents/overview).
193
194## Branch protection
195
196Branch protection rules decide how matching branches may change: required pull requests, approvals and checks, stale approval dismissal, linear history, force pushes, deletion, agent permissions and merge queues. When several rules match a branch, the rule that names the branch exactly wins; otherwise the oldest matching pattern applies. With linear history required, a merge commit is refused (`405`); squash or rebase instead. The default branch can never be deleted.
197
198Repository admins can bypass rules unless the rule sets `enforceAdmins`. A merge never bypasses silently: an admin who is a person has to ask for it (**Merge without waiting for requirements (bypass)** in the web UI, `"bypass_rules": true` on `PUT /repos/{owner}/{repo}/pulls/{number}/merge`, or `diffinery pr merge --admin`), only when the pull request has no conflicts and isn't a draft, and each bypassed merge is recorded in the audit log as `pull_request.merge_bypassed` with the unmet requirements. Each bypass covers only what it names: `"bypass_rules": true` skips unmet rules (including a merge commit on a linear-history branch), and `"bypass_merge_queue": true` skips only a required merge queue; bypassing linear history or the queue is audited too. See [Branch protection](/docs/repositories/branch-protection).
199
200Required checks are guarded against spoofing. A required check matches check runs and commit statuses by name, and Diffinery decides which reports count:
201
202- When Diffinery Actions reported a check with that name, only the Actions result counts, so another account cannot post a passing status with the same name.
203- A rule can bind a check to one reporter with `requiredCheckSources` (for example `{ "ci/external": "ci-bot" }`); then only that account's reports count.
204- Reports from agents never count.
205- Only the account that created a check run can update it, and check runs from Diffinery Actions cannot be changed through the API at all.
206
207## Push protection
208
209Every `git push` is scanned for credentials (AWS, GitHub, Diffinery, Slack, Stripe live, Google, Anthropic and OpenAI keys, Diffinery's own job, sandbox and model tokens, and private keys) before anything is stored, in file contents (UTF-8 and UTF-16 text) and in commit and tag messages. A push that contains one is rejected as a whole and nothing from it is kept. A file can opt out with a `diffinery:allow-secret` marker (a credential let through that way is still recorded in the audit log), and a repository admin who is a person can push anyway with `git push -o allow-secrets=<reason>`, which is audited and emailed to the repository's admins.
210
211The same scan runs on every commit made without a Git client: the web editor, `POST /repos/{owner}/{repo}/diffinery/commits`, the contents API (`PUT /repos/{owner}/{repo}/contents/{path}`) and the MCP `commit_files` tool, and on the git data API (blobs, trees with inline `content`, and commit and tag messages). A match is refused with `422` and recorded as `api.blocked_secret` against the caller. Only the commits API has a bypass: a repository admin who is a person sends a reason in `allow_secrets`, which is recorded as `api.secret_bypassed` and emailed to the repository's admins. See [Push protection](/docs/security/push-protection).
212
213## Tree entry names
214
215Every write that builds a tree on the server refuses file and directory names that could be dangerous in a checkout. This covers commits made through the web editor, the contents API, `POST /repos/{owner}/{repo}/diffinery/commits`, the MCP `commit_files` tool and `POST /repos/{owner}/{repo}/git/trees`. A path is refused when any of its segments:
216
217- is `.` or `..`,
218- is `.git` in any letter case, or a spelling that Windows or macOS treat as `.git`: with trailing dots or spaces (`.git.`, `.git `), the short name `git~1`, or with invisible characters that macOS ignores (such as zero-width joiners),
219- contains `\` or a NUL character,
220- is longer than 255 characters,
221
222or when the whole path is longer than 4,096 characters. The commit APIs answer `422` with `invalid path "<path>": <reason>` (for example `invalid path ".git/config": '.git' is reserved`), and the git data API with `invalid tree path ...`. Git's own `fsck` rejects the same names. Downloaded `zipball` and `tarball` archives leave such entries, and everything under them, out.
223
224Pushes made with git are checked the way GitHub checks them with git's `fsck`. A push is refused as a whole, and nothing from it is kept, when a tree in it has an entry that is empty, `.`, `..` or a spelling of `.git`, contains `/`, or appears twice; when `.gitmodules` (in any spelling) is a symbolic link; or when a `.gitmodules` file names a submodule with a `..` part, or gives a `url` or `path` that starts with `-`, which older git clients run as an option. Git prints `remote: Push refused: <reason>`. Names with `\` are allowed in pushes, as in git.
225
226## Secrets encryption
227
228Actions secrets (repository, environment and organization) are encrypted at rest with envelope encryption:
229
2301. The instance has one master key, `MASTER_KEY`, a base64-encoded secret configured on the Worker.
2312. For each scope (a repository, `repo:<id>`, an environment, `repo:<id>:env:<name>`, or an organization, `org:<id>`), Diffinery derives a separate 256-bit AES-GCM key from the master key with HKDF-SHA-256.
2323. Each secret value is encrypted with its scope's key and a random 96-bit IV. The scope is also bound in as additional authenticated data, so a ciphertext copied from one repository into another fails to decrypt.
2334. Only the ciphertext is stored. Values are decrypted inside the repository's Durable Object only when a workflow run needs them.
234
235Secret values are never returned by the API or shown in the web UI after they are saved; you can only replace or delete them. Only accounts with the admin role (organization owners and admins for organization secrets) can manage secrets, and agents can neither set nor delete them. Without `MASTER_KEY`, secrets cannot be saved. See [Secrets and variables](/docs/actions/secrets-and-variables) and, for operators, [Configuration](/docs/self-hosting/configuration).
236
237Environment secrets have extra limits:
238
239- They are never given to runs of pull request refs (`refs/pull/*`).
240- An environment that has no deployment branch policy gets a protected-branches-only policy (`protected_branches: true`) when its first secret is added.
241- Runs started by agents get them only when the environment allows protected branches only.
242
243See [Environments](/docs/actions/environments).
244
245## Secret masking in logs
246
247The runner replaces every secret value with `***` in job logs, step names, job summaries and failure messages:
248
249- A job's runner receives only the secrets the job names (`secrets.NAME`), as on GitHub, so a compromised step can't read the others.
250- All secrets the job receives are masked, including the job's `GITHUB_TOKEN`.
251- A multi-line secret is also masked line by line.
252- Values shorter than 3 characters are not masked, to keep logs readable.
253- `::add-mask::<value>` masks additional values, as on GitHub.
254- A job output whose value contains a secret is dropped with the warning `Skip output '<name>' since it may contain secret.`
255
256Masking is a safety net: a workflow that transforms a secret (for example, base64-encodes it) can still print it. See [Secrets and variables](/docs/actions/secrets-and-variables).
257
258## Job tokens
259
260Each workflow job gets its own token, available as `secrets.GITHUB_TOKEN` and `DIFFINERY_TOKEN`:
261
262- It is issued for one job of one repository and stops working as soon as the job completes. Diffinery stores only its hash.
263- It acts as `diffinery-actions[bot]` with the job's effective `permissions:` (the job's, else the workflow's, else the repository default). New repositories default to GitHub's restricted set, `contents` and `packages` read; repositories created before the setting existed default to write. Only a repository admin who is a person can change the default, with `PUT /repos/{owner}/{repo}/actions/permissions/workflow`, and each change is audited as `repo.actions_workflow_settings`. See [Workflow permissions](/docs/actions/workflow-permissions).
264- Each API endpoint needs its permission; without it the request gets `403 Resource not accessible by integration`. Reads of public repositories are always allowed, and the token can read other public repositories but no other private one.
265- Its role never exceeds write, so branch protection applies. It can't change repository settings, secrets, variables, webhooks, runners or branch protection, and it never creates, changes or deletes workflow files (`.github/workflows/`, `.diffinery/workflows/`), by push, API commit or merge.
266- It can't open, approve or request changes on pull requests unless an admin allows it (off by default), and its approvals never count toward required approvals.
267- A run whose code came from an agent acts as an agent (see [Agent guardrails](#agent-guardrails)).
268- For the [container registry](/docs/packages/container-registry), it can pull and push packages of its own repository according to the workflow's `packages` permission.
269
270[Sandboxes](/docs/agents/sandboxes) get a similar token bound to one sandbox, which acts as the person or agent that created it with at most the write role, can also read public repositories, and stops working when the sandbox is closed.
271
272## OIDC id tokens
273
274Jobs that declare `permissions: id-token: write` can request a short-lived OIDC id token to authenticate to cloud providers without stored credentials. Tokens are signed with ES256 by a key configured on the instance, expire after 10 minutes, use the instance URL as issuer (and as the default audience, unless the job asks for another one), and have GitHub's subject forms (`repo:<owner>/<repo>:environment:<name>` for a job with `environment:`, `repo:<owner>/<repo>:pull_request` for pull request runs, `repo:<owner>/<repo>:ref:<ref>` otherwise), with numeric `repository_id` and `repository_owner_id` claims that a reused name doesn't inherit. The public keys are at `https://diffinery.com/.well-known/jwks`. A job without the permission gets `403` with `this job lacks permissions: id-token: write`. See [OIDC](/docs/actions/oidc).
275
276## Webhook signatures
277
278Webhook payload URLs must use HTTPS and name a public host: `localhost`, private, loopback and link-local addresses, and Diffinery's own host are refused when the webhook is created and checked again on every delivery, so a webhook can't reach the internal network or call Diffinery itself. A repository can have at most 20 webhooks. Every delivery is signed with an HMAC-SHA256 of the body using the webhook's secret, sent as `X-Hub-Signature-256: sha256=<hex>`, the same scheme as GitHub. Diffinery does not follow redirects from your endpoint and gives it 10 seconds to respond. A delivery that fails with a network error or timeout, `408`, `429` or a `5xx` status is retried up to five times, after 1 minute, 5 minutes, 30 minutes, 1 hour and 3 hours, with the same `X-GitHub-Delivery` ID, so your endpoint can deduplicate. Retries stop when the webhook is deleted. See [Webhooks](/docs/repositories/webhooks).
279
280## Audit log
281
282Security-relevant actions go to an append-only audit log: sign-ins, passkey and token changes, agent and organization changes, profile pictures, repository settings (collaborators, branch protection, secrets, variables, webhooks, topics, workflow permissions, whether changed on the web or through the API), organization secrets, environment, environment secret and environment variable changes, cache and artifact deletions, pushes, force pushes and ref deletions, merges, admin merges that bypass branch protection or the merge queue, push protection decisions (for pushes and for commits made without Git), and package changes. Each entry records the actor, who an agent acted for, the action, the target and details. Site admins view it under **Settings**, **Audit log**. See [Audit log](/docs/security/audit-log).
283
284## Security emails
285
286Diffinery emails you about security-relevant events on your account. These emails cannot be turned off in your notification settings:
287
288| Email | When |
289|---|---|
290| New sign-in to your account | A passkey sign-in from a device not seen before, or any recovery sign-in |
291| A passkey was added / removed | A passkey is added to or removed from your account |
292| A personal access token was added / A token was created for the agent ... | A token is created for you, or for an agent you own (for an organization's agent, its owners and admins get it) |
293| Your token expires on ... | About 7 days before a token expires |
294| A new agent was added | You create an agent |
295| Your push was blocked: possible secrets | Push protection blocked your push (or your agent's) |
296| Push protection was bypassed | An admin pushed or committed past push protection in a repository you administer |
297| Your account was disabled / enabled | A site admin changes your account's status |
298
299See [Notifications](/docs/accounts/notifications).
300
301## Data isolation
302
303- **One Durable Object per repository.** Each repository's refs, Git objects, issues, pull requests, reviews, checks, workflow runs, Actions secrets, webhooks and protection rules live in that repository's own Durable Object with its own SQLite storage. It is the only writer for that repository, which also makes ref updates and merges strongly consistent.
304- **Authorization before routing.** The Worker resolves the caller and checks their role before a request reaches a repository's Durable Object.
305- **Scoped credentials.** Job and sandbox tokens name their repository and are verified by that repository's Durable Object, so they cannot change another repository or read another private one (like anyone, they can read public repositories). Secret encryption keys are derived per repository and per organization.
306- **Global data** (accounts, organizations, organization secrets, tokens, invites, notifications and the audit log) lives in a separate D1 database.
307
308See [Architecture](/docs/self-hosting/architecture).
309
310## Reporting a vulnerability
311
312Found a security problem? Report it privately with the [report form](/security/report), the first contact in [`/.well-known/security.txt`](/.well-known/security.txt). [Reporting a vulnerability](/docs/security/disclosure) explains what is in scope, the safe harbour for good-faith research and the response times we aim for.