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 /verifyanswers422.error— Pentr's lookup broke: a resolver timeout, no responding nameserver, an HTTP error, a fetch past its ten-second ceiling.POST /verifyanswers503. The launch gate retries anerror; it never retries afail.
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