A shop owner gets a strange email: "I found a hole in your website. Send money and I'll tell you where." Permitted penetration testing exists to avoid that scene. The idea is simple: invite someone to play the attacker, inside an agreed scope and time window, to find weak spots before a real attacker does. The whole thing hinges on the word "permitted".
This post covers the process at a basic level and applies only to your own systems, or systems you hold a written contract or permission for. I do not explain how to attack anyone else's system and I do not include exploit code. Every test here ran against a sample app I wrote, deliberately flawed, on my own machine.

Step 0: permission and scope
Before typing the first command, get a document signed by someone with authority over the system. People in the trade call it the Rules of Engagement, roughly an agreement on how the work will be done. It states five things. Scope: which domains and applications may be tested, and what may not, such as a third party's payment system or the hosting provider's shared services. Time: the dates and hours allowed, avoiding peak traffic. Type of testing: whether overloading the system is allowed (usually no), whether real accounts may be used, whether data may be written or deleted. Emergency contact: who to call right away if the system slows down or something serious turns up. And data handling: if you accidentally touch real data, stop, note it, report it, and do not copy it.
If the app sits on a cloud or hosting provider, read their testing policy. Unauthorised access to someone else's system is illegal almost everywhere, and "I was only looking" is not an accepted reason.
I have a habit of turning scope into code: a small script reads the scope file and refuses to send a request if the host, time, method or path is outside it. Without that gate, automated tools easily wander onto an out-of-scope host by following a link or a redirect. I re-ran it today with a sample scope file:
def check_request(scope, method, url, today=None):
# (the valid_from..valid_until date check sits at the top, trimmed here for brevity)
u = urlparse(url)
if u.netloc not in scope['in_scope_hosts']:
raise OutOfScope(f'host {u.netloc} is not in scope')
if any(u.path.startswith(p) for p in scope['out_of_scope_paths']):
raise OutOfScope(f'path {u.path} is excluded')
if method.upper() not in scope['allowed_methods']:
raise OutOfScope(f'method {method} is not allowed')
ALLOW GET http://127.0.0.1:4100/
BLOCK GET http://example.org/ -> host example.org is not in scope
BLOCK GET http://127.0.0.1:4100/admin/delete-all -> path /admin/delete-all is excluded
BLOCK POST http://127.0.0.1:4100/login -> method POST is not allowed
BLOCK (date 05/11) -> outside the permitted time window
How to do it: the five-step process

Step one is learning about the target without touching it: public pages, the technology in use, robots.txt, sitemap.xml, the HTTPS certificate. The aim is a map of features, kinds of users and APIs.
Step two is checking configuration: security headers, cookie flags, sensitive files, error pages, leaked software versions. Machines do this part well. I used the same self-check script from Self-Check Your Website's Security with the OWASP Top 10 (2025): on the deliberately weak sample app it reports 13 failures, and on the fixed version none.
Step three is sign-in and sessions: is there a limit on wrong attempts, is password reset safe, does the session ID change after login, is the login token checked for signature, expiry and audience (see A Shared JWT Login Token for Node.js, PHP, Python and C#).
Step four is permissions. A common flaw is called IDOR: a user changes an ID number in a request to read someone else's data. The basic test uses two test accounts: take account B's resource ID and call it with account A's session. I re-ran this on the sample app:
weak version fixed version
alice reads alice's order (id 1) 200 200
alice reads bob's order (id 2) 200 404 <- flaw in the weak version
signed-out read of order (id 2) 401 401
The pass rule is simple: every pair of (user, resource that is not theirs) must be refused.
Step five is what users type in. Check whether it gets glued straight into a query, a command or an HTML page. At a basic level, try a single apostrophe in the input fields on a test environment and watch for odd errors. The fix is parameterised queries and encoding output, as run in the OWASP post. I leave exploit code out, because the goal is to find and fix.
Beyond these five steps sit business logic flaws, like reusing a discount code or skipping the payment step. Machines almost never see these. It takes someone who understands the business to sit down and try.
Common defensive tools
I did not run these tools for this post, so I describe roles only and report no results. OWASP ZAP is both an intercepting proxy and an automated scanner; its "baseline" mode scans passively and suits CI, while active scans send a lot of requests and belong on a test environment. Burp Suite Community is a proxy for viewing, editing and replaying requests by hand, very good for permission and logic testing. nuclei scans with YAML templates for misconfigurations and known flaws; read the template and limit the rate before running it.
Whatever you use, tool output is a hint, not a finding. Verify by hand to weed out false alarms, because tools do not understand the business and will miss a lot.
Writing a report people can act on
A good report lets a developer fix things without guessing. Each finding should follow this shape:
### F-03: IDOR on GET /api/orders/{id}
- Severity: High (another user's order can be read)
- Description: the API checks that you are signed in, not that you own the order.
- Steps to reproduce (staging, 2 test accounts): call the API with A's session and B's order id.
- Evidence: response code 200, the total field; personal data masked.
- Impact: every customer's order details are exposed.
- Fix: check owner == caller in the query itself, return 404 on mismatch.
- Reference: OWASP A01:2025 Broken Access Control.
Order the findings by severity, state clearly what was checked and passed, mask sensitive data in the evidence, and store the report as a confidential document. Most important is the last loop: after the fix, re-test exactly the cases you reported. For the web server side of the fixes, see Making Your Nginx Website Safer, Step by Step.
Mistakes I see
I often see work start before permission exists, or a scope so vague nobody knows what may be tested. Heavy scanning at peak hours is familiar too, along with spilling onto a third party's payment gateway or embedded service. Some reports are just pasted tool output that nobody verified, and after the fix nobody re-checks.
What to do next
If you want an outsider to test your system, first write the five-point agreement from step 0, then run one round yourself with two test accounts to clear the easy problems. That way the money you pay the tester goes toward hard findings instead of ones visible at a glance. If you need someone to do this for your website, get in touch and I am happy to talk it through.



