Ownership verification

Proving you own a target before Pentr will test it.

An asset is the unit ownership is proven against: a domain, an IP address, a CIDR range or a URL, registered to your organization. A target is what a single run names. Before an engagement can start, every target and every in-scope entry must be covered by an asset whose status is verified or attested.

Assets have no screen — the Targets entry in the sidebar is greyed out — so the flows below are HTTP calls. The endpoints are /api/assets for a signed-in session and /api/v1/assets for an API key (Assets in the API section).

Registering an asset

POST /api/assets:

{"kind": "domain", "value": "example.com", "include_subdomains": true}

The asset is created unverified and given a verification token that stays the same for its lifetime, so a published DNS record keeps working across every later re-check. The response carries the token, and for a domain asset it also carries the exact record name and URL to publish:

{
  "kind": "domain",
  "value": "example.com",
  "status": "unverified",
  "verification_token": "pentr-verify-…",
  "dns_record_name": "_pentr-verify.example.com",
  "well_known_url": "https://example.com/.well-known/pentr-verification.txt"
}

For an ip, cidr or url asset those two fields are null. That is the API saying no automated method applies to it.

The two methods that ship

Two verifiers are registered, and both answer only for domain assets.

DNS TXT. Publish a TXT record at _pentr-verify.<domain> whose value is the asset's token. The check resolves that name and passes if the token is one of the strings in the answer; other records at the same name are ignored. A record longer than 255 bytes arrives split into chunks and is rejoined before comparison.

Well-known file. Serve the token at https://<domain>/.well-known/pentr-verification.txt. The fetch is HTTPS, with certificate validation, and does not follow redirects. The response must be 200, the body must not exceed 1 KB, and the body with surrounding whitespace stripped must equal the token exactly. Before the request is made the host is resolved and every answer checked: if any address is loopback, private, link-local, reserved, multicast, CGNAT or otherwise not globally routable, the fetch is refused.

POST /api/assets/{asset_id}/verify tries DNS TXT first and the well-known file second, and stops at the first pass. A pass sets the asset to verified, records which method proved it, and leaves its expiry null — a published record does not go stale on a timer.

Kinds with no automated method

ip, cidr and url assets have no verifier. Calling verify on one returns 422 with "no verification method applies to a … asset". The only route for them is an attestation.

A URL target does not need a URL asset: when no asset matches the URL exactly, coverage falls back to the URL's host and resolves that instead. So https://app.example.com/admin is covered by an app.example.com asset, or by an example.com asset with include_subdomains set — not by an example.com asset without it.

Attestation

An attestation is a written claim by a named organization owner, recorded instead of a machine check.

POST /api/assets/{asset_id}/attest:

{"justification": "Range assigned to us by our ISP; contract on file."}

The owner role is required — a member gets 403 — and a non-empty justification is required. The attester's user id and email are stored with the claim. The asset becomes attested and expires 90 days later. Nothing is checked by machine, at attestation time or afterwards; the launch gate re-reads only the expiry date.

The partner API has no attest endpoint. An API key identifies an organization, not a person, so there would be nobody to attribute the claim to.

The two usable statuses

A verified asset rests on a record Pentr fetched and fetches again at every launch. An attested one rests on a person's word, dated and attributed, and lapses after 90 days. The launch gate accepts either.

fail and error

Every attempt ends as pass, fail or error.

  • fail — your record is absent or wrong. Retrying changes nothing. POST /verify answers 422.
  • error — Pentr's lookup broke: a resolver timeout, no responding nameserver, an HTTP error, a fetch past its ten-second ceiling. POST /verify answers 503. The launch gate retries an error; it never retries a fail.

Collapsing the two would report a broken customer DNS setup when Pentr's resolver is the one that failed, and a fail is never retried, so that verdict would stand.

The DNS check sorts the two by the kind of failure the resolver reported. A name that does not exist and a name carrying no TXT record are fail. A timeout, an unreachable nameserver and any other resolver failure are error. The address resolution behind the well-known fetch is split the same way: a resolver that broke gives error, a host that genuinely resolves to a private address gives fail.

When both verifiers run and neither passes, POST /verify reports the last verifier's outcome rather than the worst one. A DNS error followed by a well-known fail therefore answers 422, not 503. The launch gate does the opposite — see below — so the two surfaces can disagree about the same asset. Both attempts are in the log either way.

The attempt log

Every attempt is appended to the asset's verification log — passes, failures and refusals alike, whether a human ran them or the launch gate did. Rows are never updated and never deleted. Each row records the method, the outcome, the reason, a SHA-256 digest of the evidence, the trigger (manual or launch), the engagement it gated if it gated one, and the acting user if there was one.

GET /api/assets/{asset_id}/verifications returns the log, newest first.

An asset that has any verification history cannot be deleted; the log is the audit trail for the engagements that asset authorized, and it has to stay attached to them. DELETE /api/assets/{asset_id} on such an asset returns 409.

The re-check at launch

Starting an engagement re-proves ownership from scratch. Creating one proves nothing: creating an engagement and starting it are two separate steps.

The gate collects the identifiers to prove — the targets plus the in-scope list, normalised and de-duplicated. Those are the gated identifiers; the out-of-scope list is not among them and is never checked. For each gated identifier the gate finds the asset that covers it — its covering asset — preferring a usable one, and refuses the launch if that asset is missing, unverified, revoked, or an expired attestation.

Then it re-runs the network check, once per covering asset rather than once per identifier. A verifier that returns error is retried up to twice with a short backoff; one that returns fail is not retried. When an asset's attempts produce no pass, the most severe outcome is the one reported, so a resolver outage surfaces as an outage rather than as "your record is wrong". Attested covers skip the network entirely and are checked for expiry alone, and each one gets a pass row explaining why the launch was allowed.

An engagement with no targets and no in-scope entries is refused here too, under the identifier (engagement scope).

If anything is unproven, nothing is queued. The engagement moves to blocked and the submit call answers 409. The body is a FastAPI error envelope, so the per-identifier list sits under detail:

{
  "detail": {
    "message": "Ownership could not be verified for every target.",
    "blocked": [
      {"identifier": "staging.example.com",
       "reason": "no TXT record at _pentr-verify.staging.example.com",
       "outcome": "fail"}
    ]
  }
}

The partner API's submit returns the same shape (Engagements).

blocked is not terminal. Publish the missing record and submit the same engagement again.

What this does not cover

The out-of-scope list is never verified. It is an exclusion list, so nothing on it has to be owned by you — and nothing on it is checked before it reaches the agent.

Proof is taken at launch, not held for the run. A record can change while a container is running, and nothing re-checks it mid-run.

A proven asset cannot be retired. No route sets revoked, and an asset with verification history cannot be deleted. The gate handles a revoked asset correctly if one ever appears; nothing produces one.

Three methods named in the API do not exist. The VerificationMethod schema declares dns_txt, file_token, well_known, ptr, rdap_email and attestation. Only dns_txt, well_known and attestation are ever written as the method on a verification row, alongside the sentinel none recorded when no method applied or the gate's own machinery failed. file_token, ptr and rdap_email appear nowhere outside that list of names. There is also a complete asset_challenges table for an email-based challenge — code hash, recipient, expiry, attempt count — with no verifier behind it. Do not plan around any of them.

Last updated 2026-09-24