How an API approval card shows the real target
Build an API approval card that exposes the real HTTP target by canonicalizing scheme, host, port, method, path, and encoded URLs.

A reviewer cannot approve an HTTP action from a string that merely resembles a destination. The card must show the request target the transport will actually use, after parsing and before credentials leave the machine.
That sounds obvious until an agent submits HTTPS://API.EXAMPLE.TEST:443/%76%31/../admin, a client accepts it, and the human sees a shortened label such as api.example.test. A card like that does not ask for informed consent. It asks the person to trust a renderer that may disagree with the HTTP stack.
The fix is not to teach reviewers every corner of URL syntax. The fix is to make one canonical request description, render it plainly, and ensure the executor uses that same description. Scheme, host, effective port, method, and path are the minimum. Query values, redirects, caller-controlled headers, and body identity often belong beside them because they can change the action just as sharply.
How an approval card earns a yes
An approval card earns a yes only when it names the network action in terms the reviewer can verify. A hostname alone is an identity claim, not a request description. POST https://billing.example.test/v1/invoices/481/refund tells a person far more than billing API or example.test.
Put the action line first, in a fixed order:
POST https://billing.example.test/v1/invoices/481/refund
Then put the details that alter meaning immediately below it:
Authorization: injected from vault entry "billing-production"
Query: dry_run=false
Body: JSON, 214 bytes, sha256: 7b1f...c0a9
Do not put the credential value, an authorization placeholder, or a friendly integration name where the target should be. Those labels may help a reviewer recognize context, but they are not proof of destination.
Method belongs on the first line because it changes the consequence of the same path. GET /exports/481 and DELETE /exports/481 are not variations of one action. They are different actions and should never collapse into a single approval label.
Path belongs on the first line because API routing usually lives there. A card that displays api.example.test forces the reviewer to infer whether the agent is reading a profile, creating an access token, or deleting a project. That is a poor use of an approval interruption.
Canonicalization is a display contract, not permission matching
Canonicalization answers, "What should a human see for this parsed request?" It does not answer, "Which destinations are allowed?" Teams often mix those jobs and create a brittle allowlist disguised as friendly formatting.
For an approval card, create a structured target record after parsing:
{
"method": "POST",
"scheme": "https",
"host": "api.example.test",
"port": 443,
"port_display": null,
"path": "/v1/invoices/481/refund",
"query": "dry_run=false",
"raw_url": "HTTPS://API.EXAMPLE.TEST:443/v1/invoices/481/refund?dry_run=false"
}
The executor should take the same structured fields, or a URL serialized from them. Do not parse once for the card and later pass the original string to another library. That split is where a review screen becomes theater.
RFC 3986 distinguishes several safe normalization categories. It treats scheme and host as case-insensitive, recommends uppercase hexadecimal digits in percent escapes, and describes removal of dot segments. It also warns that code must parse URI components before decoding percent-encoded octets, because decoding at the wrong time can turn data into delimiters. That warning is practical engineering advice, not specification trivia.
Keep a second record for the raw agent input. It belongs in the activity record and in a details view when someone needs to investigate an odd request. It should not compete with the canonical action line for the reviewer's attention.
A useful rule is simple: the card displays a normalized description, the journal preserves both description and input, and authorization decisions use neither as a substitute for explicit scope rules. They are separate data products.
Parse first and reject inputs the transport cannot explain
A URL parser is part of the security boundary once a human approves its output. Pick one parser behavior for the supported URL schemes and make it the source of truth for both rendering and execution.
For ordinary HTTP APIs, reject inputs that leave uncertainty rather than trying to be helpful. Relative references need an explicit base URL before they have a host. Fragments do not go on an HTTP request and should not appear as if they influence the server. Userinfo such as https://[email protected]/ is nearly always misleading in an API approval flow and should be rejected, not quietly hidden.
Use a parse pipeline with a clear failure point:
- Accept only an absolute
httporhttpsURL for the outbound API channel. - Parse it with the URL implementation chosen by the action executor.
- Reject userinfo, missing host, malformed port values, unsupported schemes, and invalid percent escapes.
- Build the actual request from parsed components and approved headers.
- Render the card from those components, then submit that exact request.
Do not hand-roll this with string splits. The first @, :, /, ?, and # do not all mean the same thing in every position. IPv6 authorities need brackets. A colon after a bracket can introduce a port, while colons inside the brackets are part of the address. A parser knows that distinction; a short regular expression generally does not.
The WHATWG URL Standard defines parsing and serialization behavior for URLs, hosts, domains, and IP addresses. Its security guidance also calls out the confusion that bidirectional text can create between a host and a path, and advises rendering the host alone in that situation. A security product should take the stricter lesson: keep the authority visually distinct from the path in every card, not only for unusual strings.
If the action layer has a custom client, prove that it agrees with the parser on a test corpus. Do not assume two mature libraries have identical tolerance for spaces, backslashes, Unicode hostnames, or unusual numeric IP forms. Agreement is a property you test.
Decode percent escapes without changing the route
Percent encoding creates the most dangerous kind of misleading URL: one that looks harmless after a casual decode but means something else to the router, proxy, or upstream service.
Consider these paths:
/v1/projects/%2E%2E/admin
/v1/projects/%252E%252E/admin
/v1/files/report%2Ffinal
The first contains percent-encoded dots. The second contains an encoded percent sign followed by 2E, which is not the same input. The third contains an encoded slash within one path segment. If a display layer decodes all three repeatedly until it reaches readable punctuation, it can display a path structure that the client did not send.
RFC 3986 gives a narrow safe case: percent escapes for unreserved characters can be decoded during normalization. Unreserved characters are letters, digits, hyphen, period, underscore, and tilde. Reserved characters such as /, ?, #, @, and : must remain encoded when their decoding would change component boundaries or delimiters. The RFC also says an implementation must not encode or decode the same string more than once.
That produces a good display rule:
Raw path: /v1/%75sers/alice%7Eops/report%2Ffinal
Card path: /v1/users/alice~ops/report%2Ffinal
Wire path: /v1/users/alice~ops/report%2Ffinal
The card makes %75 and %7E readable because they represent unreserved characters. It keeps %2F visible because a slash would alter the path's segment structure. The wire form and card form can differ in harmless ways, but they must retain the same routing meaning.
Do not remove dot segments after indiscriminately decoding the path. Parse the path into its encoded structure, apply a defined normalization procedure, and preserve any escapes that carry reserved characters as data. If the downstream service applies a different decode order, that is a compatibility and security problem worth exposing in tests, not a reason for the card to guess.
A hostname is not the whole authority
The authority of an HTTP target includes the host and, when it is non-default, the port. Leaving out the port makes a review card lie by omission.
Treat these as distinct:
https://api.example.test/v1/keys
https://api.example.test:8443/v1/keys
http://api.example.test/v1/keys
The first normally uses port 443. The second uses port 8443. The third uses a different scheme and normally port 80. A reviewer may accept a production API call over HTTPS and reject a request to a test listener on a custom port. The card must let them make that decision.
Normalize the scheme and hostname to lowercase. Suppress the port only when it is the default for the parsed scheme: 80 for http, 443 for https. Do not suppress a port because a DNS record happens to lead somewhere familiar.
Internationalized domain names need equal care. A human-friendly Unicode form may be easier to read, while the DNS wire form uses ASCII labels. If you display Unicode, display the ASCII form in the details at the same time and use a parser that applies a defined host-processing algorithm. Do not invent your own punycode conversion or compare display strings to decide equivalence.
IP literals deserve their own treatment. Render brackets around IPv6 addresses, preserve a non-default port, and label a literal address as an IP literal. A request to https://[2001:db8::9]/v1/keys should not look like a named production service simply because an agent supplied a pleasant alias in a note field.
Aliases create a different issue. api.internal, api, and 10.0.0.9 may end at the same server today, then diverge after a DNS change. Do not silently rewrite one into another for approval. Show the parsed authority the client requested. If the system resolves DNS before opening a connection, show the selected address as connection context and record it in the audit trail. The authority remains the thing the HTTP request names.
HTTP makes that distinction explicit. RFC 9110 says the Host field provides host and port information from the target URI, while HTTP/2 and HTTP/3 can carry that information in :authority. RFC 9113 says an intermediary generating Host from HTTP/2 authority must use :authority unless it changes the request target. A card must therefore treat a caller-supplied authority field as routing material, not decorative metadata.
Method and path need their own visual weight
Put the HTTP method, authority, and path together because reviewers read the action as a sentence. Then give the method and dangerous path segments enough contrast that a quick glance does not flatten them into a long URL.
This layout works because it keeps the parts in a stable order:
DELETE
https://api.example.test/v1/projects/acme/production
For a request that changes an object, include the identifier in the visible path. Truncating the end of /v1/projects/acme/production to fit a card is backwards. If space is tight, truncate long query values or body previews first, never the final path segment that identifies the target.
Case must survive in the path. RFC 3986 says generic URI syntax treats components other than scheme and host as case-sensitive unless the scheme says otherwise. Many frameworks route case-sensitively even when a particular API happens not to. Lowercasing /Admin/DeleteUser into /admin/deleteuser creates a statement about a request that was never made.
A request path can also look benign while its query changes the effect:
POST https://api.example.test/v1/invoices/481/refund?dry_run=false
POST https://api.example.test/v1/invoices/481/refund?dry_run=true
Show a short query summary beneath the action line when parameters alter scope, behavior, or identity. For unstructured or very long queries, show the full encoded query in an expandable details area and a redacted, decoded summary in the main card. Never decode a token into a readable secret merely because the card wants to be friendly.
The request body may matter even more than the path. An approval for PATCH /v1/users/alice says little if the body can grant an administrator role. Show content type, byte length, and a stable digest at minimum. For structured formats such as JSON, a small preview of changed fields can help, provided the preview comes from the same bytes that will go on the wire. Re-serializing an object for display after signing or hashing a different byte sequence causes the same split-brain problem as reparsing URLs.
Headers and redirects can change where the request goes
A canonical URL does not save an approval flow if another request field can steer the connection. The card must either constrain those fields or render their effect.
Start with Host and :authority. An HTTP client normally derives them from the target URL. If an action interface lets the caller override them, reject the override unless the transport has a documented reason to support it. If you support it, the approval line needs to show both the connection destination and the requested authority in terms a person can compare.
Proxy configuration deserves the same treatment. A proxy changes the immediate peer, but it does not necessarily change the origin target. Do not replace the origin with the proxy address on the card. Show the origin as the action being approved and show the proxy as transport context. If the proxy can rewrite destination fields, treat it as an executor component with tests, logging, and a separate trust decision.
Redirects are fresh actions when their target changes. A POST can become a GET under some redirect behavior, or it can be resubmitted to a new authority. The original approval should cover only the original target. Before following a redirect, parse the Location value, construct the proposed next request, compare its scheme, authority, method, path, query, and body behavior, then ask again if anything meaningful changes.
Avoid the popular shortcut of approving a "site" for the duration of a run and treating every redirect below it as harmless. It feels less interruptive, but it turns URL parsing and redirect policy into invisible privilege expansion. If the run needs broad permission, make that scope explicit in the approval wording rather than letting redirects smuggle it in.
Put raw input in the record, not in the decision line
An audit trail needs enough detail to answer two separate questions: what did the agent ask for, and what did the executor attempt? One URL string cannot always answer both.
Record the raw URL string exactly as received, subject to secret redaction rules. Record the canonical target separately. Add the final connection authority and resolved address if the executor performed resolution. For HTTP/2 or HTTP/3, record the effective :authority; for HTTP/1.1, record the effective Host value. Record redirect hops as individual attempted requests, not as a footnote on the first call.
A journal entry can have this shape:
{
"request_id": "req_01J...",
"agent_input_url": "HTTPS://API.EXAMPLE.TEST:443/v1/%75sers/alice%7Eops",
"approved_target": "GET https://api.example.test/v1/users/alice~ops",
"effective_authority": "api.example.test",
"effective_port": 443,
"connection_ip": "203.0.113.42",
"result": "200"
}
The sample uses documentation address space for the connection IP. In a real log, protect query secrets, authorization material, and sensitive bodies before data reaches any long-lived record. A digest helps connect the approved content to the executed content without copying private payloads into every screen.
The distinction between raw input and canonical target pays off during an investigation. If a card showed a normal path but the raw input contained layered encodings, you can determine whether the parser, renderer, or HTTP client disagreed. If you stored only a pretty URL, you threw away the evidence needed to find the defect.
Sallyport's Activity journal and Sessions journal are projected from one encrypted, hash-chained audit log, so a target representation should be written once as part of the action record rather than reconstructed later from UI strings. Its offline sp audit verify check is useful only if the recorded action fields were honest at the time of execution.
Test the disagreements that ordinary URLs never reveal
The unit tests that matter are not ten ordinary https://api.example.test/v1/users examples. They are cases where a raw string, a card renderer, and a transport library could disagree.
Build a table-driven corpus that asserts the parsed fields, visible target, wire target, and decision. Include at least these families:
- scheme and host case changes, with default and non-default ports;
- dot segments and percent escapes for both unreserved and reserved characters;
- encoded percent signs, encoded slashes, and malformed escape sequences;
- IPv6 literals, Unicode host input, and userinfo that must be rejected;
- query values that change action behavior, plus redirect targets on another authority.
A test case should make the expected representation explicit:
{
"input": "HTTPS://API.EXAMPLE.TEST:443/v1/%75sers/alice%7Eops?role=viewer",
"decision": "approve",
"card": "GET https://api.example.test/v1/users/alice~ops?role=viewer",
"wire_url": "https://api.example.test/v1/users/alice~ops?role=viewer"
}
Then add negative cases that must fail before approval:
{
"input": "https://[email protected]/v1/users",
"decision": "reject",
"reason": "userinfo is not supported for outbound API actions"
}
Run the corpus through the exact client code that opens the connection. A parser-only test suite catches rendering mistakes but misses transport behavior such as a library normalizing an empty path, injecting a default authority, or applying its own redirect rules.
Finally, test the UI as a reviewer uses it. Confirm that the complete method, host, port when present, and final path segment remain visible at ordinary window sizes. Security copy fails when the person must hover, expand, or scroll to learn that a request deletes production data. The card should make the important difference visible before the approval button receives focus.
A human approval can be a strong control, but it is only as strong as the request description placed in front of the human. Build that description from parsed components, execute those components, preserve the raw input for later scrutiny, and reject ambiguity instead of decorating it.
FAQ
What URL should an API approval prompt display?
An approval card should show the parsed destination that the HTTP client will use: scheme, hostname, effective port, method, path, and any action-changing query values. Keep the submitted string available as evidence, but do not make it the main thing the reviewer must interpret.
Should an approval card decode percent-encoded URLs?
Decode percent escapes only after parsing the URL into components, and only where decoding cannot turn data into syntax. RFC 3986 permits normalizers to decode escapes for unreserved characters, but decoding %2F into / changes a path segment into a separator.
Should default ports be hidden in API approval prompts?
Usually, yes. https://api.example.test:443/payments and https://api.example.test/payments reach the same default HTTPS port, so showing them as different targets gives attackers room to create visual noise. Preserve a non-default port because it changes the authority the client contacts.
Are URL paths case-sensitive in approval cards?
No. Lowercase the hostname for comparison and display, but treat path case as meaningful unless the destination's own rules say otherwise. Many servers route /Admin and /admin differently.
Do query parameters belong in an API approval prompt?
Yes, when it affects the action. A destructive operation hidden behind a harmless-looking query string is still destructive, so show the query or a clear summary of its decoded parameters. Redact secrets such as signed tokens rather than omitting the whole query.
Can an approval card treat host aliases as the same destination?
No. An IP address, a local hostname, a Unicode domain, and a public DNS name can all create different risks even when they appear related. Record aliases and resolved addresses as supporting context, but approve the literal parsed authority unless your transport layer deliberately rewrites it.
Should HTTP redirects require another approval?
A redirect is a new request target and needs a fresh decision when the scheme, host, port, method, or meaningful path changes. Approving the first URL does not give a client permission to follow a later hop to a different authority.
What is the safe order for parsing and approving an outbound URL?
The safe order is parse, validate the scheme and URL structure, construct the actual request, then render the request target from those structured values. Rendering the raw input first invites disagreement between the card and the transport library.
Should audit logs store the original URL or the canonical URL?
Store both, but give them different jobs. The raw string helps an investigator reproduce what the agent supplied, while the canonical target tells the reviewer what the client attempted to contact.
Can a custom Host header mislead an API approval screen?
A request can carry a valid-looking URL while a caller-supplied Host header, proxy setting, redirect rule, or custom transport sends it elsewhere. Build the final authority in one place and either reject conflicting routing fields or render them prominently as part of the destination.