← Back to Blog

Audit Your Own Web App's Security Before You Pay Anyone: The Two-Phase AI Method

· 23 min read Audit Your Own Web App's Security Before You Pay Anyone: The Two-Phase AI Method

A penetration test quote for a small web app usually lands somewhere between $3,000 and $8,000. The automated SaaS scanners are cheaper, $50 to $200 a month, but they crawl your unauthenticated surface, miss the logic bugs that actually matter, and keep a copy of every finding on someone else's server. Neither is a bad thing to buy eventually. But if you built the app, you are the person best placed to do the first pass, and with an AI coding assistant driving, you can do a genuinely thorough one for the price of a subscription you probably already have.

The reason this works now is not that AI "hacks" your site. It is that a good security review of your own app is mostly two boring disciplines applied carefully: read the code for the checks it forgot to make, and probe the running app to confirm which of those gaps are real. An assistant that can do both at once, read your source and run the test and then write the fix, closes the loop that used to require a consultant.

This post is the method I use, generalised so you can run it against your own app. It splits cleanly into two phases, because security splits cleanly into two phases: what anyone can see from outside, and what only you can see once you are logged in and looking at your own source. At the end there is a full copy-paste prompt that turns the whole thing into one job for your assistant.

Throughout I will use exampleapp.com as a stand-in for a small app on the common modern stack: serverless functions serving a JSON API, a SQL database behind them, and a drop-in auth library for sessions. The method is stack-agnostic. Swap in your own. It works just as well when your "app" is a WordPress or WooCommerce store or another off-the-shelf platform, and there is a section below for exactly that. It also deliberately looks past the application to the machine underneath it, because in practice the most serious thing I find is usually not in the code at all.

The mental model: grey-box, and two frameworks

"Grey-box" means you review with partial knowledge. You are not a black-box attacker guessing blindly, and you are not only reading source in a vacuum. You do both, and you let each inform the other. It is the most honest posture for auditing your own thing, because you genuinely have both the outside view and the code.

Two checklists cover almost everything worth finding on a web app in 2026, and you should run against both:

  • The OWASP Top 10 (the classic web list) catches the injection, misconfiguration, and access-control classics.
  • The OWASP API Security Top 10 (2023) catches the ones the classic list underweights, and it is where most modern bugs actually live, because most modern apps are a thin front end talking to a JSON API. If you only run one, run this one.

The single most useful reframe: on a modern app, your "site" is the marketing pages, but your attack surface is the API. A scanner that grades your homepage an A can be sitting in front of an API that hands any logged-in user anyone else's data.

The golden rule before you touch anything

You are authorised to test systems you own or have written permission to test. That is the whole permission slip, and it does not extend one inch past it. With that settled:

  • Run against staging (or a preview deploy) if you have one. If you must use production, do it at low traffic.
  • Back up your database first. One command, every time.
  • Use dedicated test accounts, never real users, and never fire flows that email real people (invites, password resets, notifications).
  • Non-destructive by default: read-only requests, no brute force beyond a dozen attempts, no volumetric floods, no third-party targets. Clean up test rows when you are done.

Everything below obeys those rules. The AI prompt at the end restates them so your assistant obeys them too.

Phase 1: outside-in, non-destructive

This is the half a scanner does well, and the half you can do in an afternoon with curl and your browser. The goal is not to break in. It is to inventory the surface and catch the misconfigurations that are visible without a login.

Headers, TLS, and the discovery files

Start with the response headers. A strong baseline in 2026 is a real Content-Security-Policy, HSTS, X-Content-Type-Options: nosniff, X-Frame-Options: DENY (plus CSP frame-ancestors 'none'), a Referrer-Policy, a Permissions-Policy, and the COOP/CORP isolation pair. I wrote the whole baseline up separately in The Six Security Headers Every Site Should Ship in 2026, and you can grade a URL in one click with the Security Headers Audit or the broader Mega Security Analyzer if you would rather not touch a terminal.

If you like the terminal, the whole header set is one request:

curl -sSI https://exampleapp.com/

TLS and DNS a browser cannot see; a server-side probe can. Confirm your edge negotiates TLS 1.2/1.3 with modern ciphers, and that SPF plus DMARC p=reject are set so nobody spoofs your domain. The DNS / Email Auth Audit does the email side; more on why those probes need a server in Three Server-Side Probes That Let Browser-Based Audits See Past the Browser.

Content discovery: what should 404

Attackers look for things you left lying around. You should look for the same things and confirm they are gone. None of these should return a 200:

/.git/HEAD      /.env       /.env.local     /config.json
/app.js.map     /styles.css.map             /package.json
/backup.sql     /.well-known/security.txt (this one SHOULD 200)

Source maps are the quiet one. If /app.js.map returns a 200, your minified bundle just handed a reader your original, commented source. security.txt, on the other hand, should be present and valid; it is a trust signal as much as a security one.

CORS, methods, and the auth-route fingerprint

Two quick checks that catch real misconfigurations:

# Does the API reflect an arbitrary origin? (It should NOT.)
curl -sSI -H "Origin: https://evil.example" https://exampleapp.com/api/me | grep -i access-control

# Is TRACE disabled? (It should be: expect 405.)
curl -sS -o /dev/null -w "%{http_code}\n" -X TRACE https://exampleapp.com/

Then fingerprint your own auth surface with GET requests only (a GET never triggers the dangerous POST action, so this stays non-destructive). This is where a lesson worth internalising lives: a known CVE only matters if the vulnerable feature is actually turned on.

A drop-in auth library might have had a critical account-takeover CVE in one of its optional plugins, say the API-keys plugin or a magic-link plugin. Before you panic about the version, check whether that route even exists:

curl -sS -o /dev/null -w "%{http_code}\n" https://exampleapp.com/api/auth/api-key/create

If that returns 404, the plugin is not mounted, and the CVE that targets it does not apply to you, regardless of your version. I have watched a scanner flag a "critical" that was structurally impossible on the target because the vulnerable feature was never enabled. Confirming the surface before you rate the risk is the difference between a report that helps and a report that cries wolf. (You still pin and patch the library, for the core advisories that are not plugin-gated. Run osv-scanner or turn on Dependabot to catch those.)

The layer under the app: what is actually listening

Here is the finding that humbles a clean code review. You can harden every route, escape every field, and still be one open port away from a breach, because your application is not the only thing answering on your server. The database, an FTP daemon, a cache, an admin panel: each is its own front door, and none of them care how good your CSP is.

So before you leave Phase 1, ask a blunt question: what on my server actually answers the internet? You do not need a port scanner installed to get the answer that matters. A handful of TCP connections will tell you whether the dangerous ports are open, and reading the first bytes a service sends back (its banner) tells you what it is, without logging in to anything:

# What actually answers on your server? (read-only: connect, read the greeting, close)
for p in 3306 5432 6379 11211 21; do
  (exec 3<>/dev/tcp/exampleapp.com/$p) 2>/dev/null \
    && echo "port $p OPEN  <- should this really be public?" || echo "port $p closed (good)"
done

The two you never want open to the world are your database (3306 MySQL/MariaDB, 5432 Postgres) and any in-memory cache (6379 Redis, 11211 memcached), because those often ship with weak or no authentication. Plain FTP (21) is a close third: it sends your password in cleartext. A database or a Redis instance reachable from the open internet is, in my experience, the single most common serious finding on a small site, and it is invisible to every tool that only speaks HTTP.

Two cautions. First, if you are on shared hosting that IP is not only yours, so keep any scan scoped to the specific ports you care about and never blast the whole range, because you would be scanning your neighbours too. Second, and this is the mindset that matters most everywhere in this post: patched is not the same as safe. I have found a database running the very latest, fully-patched version that was still a critical finding, purely because it was reachable from the internet. Being current buys you nothing the moment the next flaw drops if anyone in the world can reach the port. Exposure is the finding; the version is a footnote.

Is anything in front of it? WAF detection

One more Phase 1 question: is there a web application firewall between the internet and your app? You can tell in seconds by sending an obviously hostile-looking (but completely harmless, never-executed) request and seeing whether something blocks it before your app even runs:

# No WAF? An obvious probe sails straight through with a 200 instead of a 403.
curl -sS -o /dev/null -w "%{http_code}\n" "https://exampleapp.com/?q=<script>alert(1)</script>"

If a request carrying a <script> tag or a ../../etc/passwd string in the query passes through with a 200, there is no request firewall in front of you and the app is on its own. That is not a vulnerability by itself, but a WAF is the cheapest meaningful layer you can add, and on anything that takes payments it is close to mandatory. A CDN's free tier (Cloudflare and others) gives you one and hides your origin IP at the same time, which also helps with those exposed ports above.

The honesty test: a real sink is not always a real bug

Here is the discipline that separates a useful audit from a scary one. You will find unescaped output, code that drops a value straight into the page without encoding it. That is a real defect and you should fix it. But whether it is an exploitable cross-site scripting hole depends on where it renders.

I once found a school-name value written straight into a dropdown with no escaping. On paper that is stored XSS. In reality the value rendered inside a <select> element, and the browser's HTML parser, in "in select" mode, silently drops injected <img> and <script> tags. The classic breakout payload never executes there. The same unescaped value written into a <div> a few functions away did execute. Same bug class, two contexts, two completely different severities.

The lesson: verify reachability before you assign severity. A finding that reads HIGH on pattern-match can be LOW in context, and a "minor" inconsistency can be the live one. An AI reviewer is genuinely good at this if you tell it to reason about the render context and the data's origin rather than pattern-match. Tell it to.

Phase 2: inside-out, the part scanners can't reach

Everything above, a good scanner also does. This is the half it cannot, because it requires a valid session and your source code, and it is where the findings that actually hurt live. Authorisation bugs do not show up on an unauthenticated crawl. You have to be logged in, and ideally logged in as two different users, to see them.

The easiest way to send authenticated requests is your own browser. Log in, open the developer console (F12), and paste a fetch(). Because it runs on your own origin, your session cookie rides along automatically, no token copying required:

fetch('/api/me').then(r => r.json()).then(console.log)

For the authorisation tests you want two accounts, "User A" and "User B", in two browser profiles. Here is the battery, each item mapped to the API Top 10 class it checks.

Broken Object Level Authorization (BOLA / IDOR) — API1

The number one API vulnerability, every year. The server hands you an object by ID; does it check that the object is yours before handing it over? Grab an object ID that belongs to User A, then, in User B's session, try to read or change it:

// As User B, using one of User A's object IDs:
fetch('/api/orders/A_OBJECT_ID').then(r => console.log(r.status))
fetch('/api/orders/A_OBJECT_ID', {method:'DELETE'}).then(r => console.log(r.status))

Pass: 401/403/404, and User A's data is untouched. Fail: 200, or the record changes. If it fails, the server is trusting the ID in the URL instead of checking ownership against the session. The fix is always the same: re-derive the user from the session and confirm they own the object before doing anything.

Broken Function Level Authorization (BFLA) — API5

The client hides the "admin" and "owner-only" buttons from users who should not see them. Does the server enforce that, or does it just not draw the button? As a non-privileged User B, call a privileged endpoint directly:

fetch('/api/projects/A_PROJECT_ID/cancel', {method:'POST'}).then(r => console.log(r.status))

Pass: 403. Fail: it works. Hiding a button is not access control.

Mass assignment — API3

Send fields the form never offered and see if the server accepts them:

fetch('/api/profile', {method:'PATCH', headers:{'Content-Type':'application/json'},
  body: JSON.stringify({ name:'Test', role:'admin', is_admin:true, verified:true })
}).then(r => r.json()).then(console.log)

Pass: the extra fields are ignored (the server allow-lists what you can write). Fail: you just made yourself an admin. Same idea for any field derived on the client, like a team or price or tier that the server should compute rather than trust.

Stored XSS via client-controlled fields

The form gives you a dropdown of four options. The API does not care about the dropdown. Send a value that was never on the menu, then view it as the other user:

// As User A, send a value the UI never offered:
fetch('/api/messages', {method:'POST', headers:{'Content-Type':'application/json'},
  body: JSON.stringify({ to:'B_ID', kind:'<img src=x onerror=alert(document.domain)>' })
}).then(r => console.log(r.status))

Then open User B's view. Pass: the server rejected the off-menu value (400), or it renders inert and escaped. Fail: an alert box in User B's browser. The real fix is server-side enum validation; escaping on the client is the belt to that suspenders.

Password-reset redirect, and the token-theft trap

If your reset flow accepts a redirectTo (many auth libraries do), test whether an attacker can point it off-domain:

curl -sS -X POST https://exampleapp.com/api/auth/request-password-reset \
  -H "Content-Type: application/json" \
  -d '{"email":"YOUR_TEST_EMAIL","redirectTo":"https://evil.example/reset"}'

Now open the email. Pass: the link points at exampleapp.com. Fail: the link (carrying the reset token) points at evil.example, which is account takeover. The fix is to ignore the client value and build the URL server-side, or to allow-list it against your library's trusted-origins setting.

The rest of the battery

  • CSRF and cookie flags. In dev tools, confirm the session cookie is HttpOnly, Secure, and SameSite=Lax or Strict. Then confirm state-changing requests are protected (SameSite plus an Origin check).
  • Admin brute-force resistance. On your own admin login, enter a dozen wrong passwords and watch for a lockout, a rate-limit, or a CAPTCHA. Nothing happening is the finding.
  • Email / header injection. Submit free-text fields that end up in an email or an admin console with markup and a \r\n in the address field. Check the received email's raw source. Escaping and CR/LF rejection is the fix.
  • SQL injection, by source review. You have the code; grep every database call. Every one should use bound parameters (prepare(...).bind(...)), never string concatenation of user input into SQL.
  • Session lifecycle. Change your password with "log out other sessions" on, and confirm the other session actually dies. Confirm a signed-out cookie is dead server-side, not just cleared client-side.
  • Excessive data exposure. Read a list endpoint and check it is not quietly returning fields (emails, phone numbers, internal flags) the UI never shows.

If you run WordPress, WooCommerce, or any off-the-shelf platform

Most small sites are not custom apps; they are WordPress, WooCommerce, Shopify, or something similar, assembled from a theme and a stack of plugins. The two-phase method still applies, but the centre of gravity moves. You are not reading much of your own code; you are auditing versions and third-party code you did not write. The good news is that most of it is checkable from the outside.

Fingerprint the versions. A WordPress site announces its core version in /feed/ and often in /readme.html, and each plugin and theme carries its version in a readme.txt (Stable tag:) or in its asset URLs. Pull them, then check each against the current release and the vulnerability databases. wpscan automates the whole thing (wpscan --url ... --enumerate vp,vt,u --api-token ...) and is the right primary tool here; Wordfence Intelligence and Patchstack are the databases to cross-reference by hand.

But "up to date" is only half the answer, for the same reason exposure beat version on the database. Two traps:

  • A plugin can hide its version. When a plugin's readme.txt is renamed or missing, no external scanner can grade it, which means the plugins you cannot see from outside are exactly the ones you have to open the admin panel and check by hand. In my experience those are disproportionately the important ones.
  • Not all plugins are equal. A plugin that runs code (anything that executes admin-supplied PHP), moves money (a payment gateway), or writes to your orders (a point-of-sale, a bulk importer) is a completely different risk tier from one that adds a testimonial slider. And a plugin distributed outside the official directory, a premium or bespoke gateway, has no public advisory feed at all, so no scanner will ever vet it. Those earn a manual, vendor-level review, especially anything touching payments.

The WordPress-specific Phase 1 checks are quick and worth doing every time:

  • User enumeration. /wp-json/wp/v2/users and /?author=1 should not hand a stranger your admin usernames. If they do, that is half of a brute-force attack handed over for free. A well-hardened site returns 401 or 403 on both.
  • XML-RPC. An enabled /xmlrpc.php is a brute-force and DDoS-amplification surface; disable it unless you use a feature that needs it.
  • Version-leaking files. /readme.html and /license.txt exist mainly to tell a scanner exactly what to throw at you. Block them.
  • The login page. Add two-factor and a login rate-limit; the default form has neither.

The Phase 2 instinct still pays off here, just pointed at plugins instead of your own routes: an importer or a point-of-sale plugin is exactly the kind of code that forgets an object-ownership or a capability check, and it is handling your real orders.

The tooling landscape, honestly

You do not need a lab of scanners, but it helps to know what each actually adds on a small, already-hardened app:

Tool What it adds here
OWASP ZAP / Burp Suite Real value with a logged-in session: point them at the API to fuzz object IDs (BOLA) automatically. Free (ZAP) is plenty for a first pass.
WPScan The right primary tool if you run WordPress: enumerates core/plugin/theme versions and cross-references its vulnerability database. Free with an API token; aggressive detection forces hidden plugin versions out.
nmap The port/service check the HTTP tools cannot do: what is actually listening (database, cache, FTP, admin panels). Scope it to your host and specific ports on shared hosting.
testssl.sh A full TLS audit (protocols, ciphers, downgrade risks) beyond the "does 1.3 negotiate" glance.
Nuclei A repeatable exposure/CVE regression baseline (it has WordPress and CVE template packs). Won't find your logic bugs; great as a scheduled canary, and noisy enough to keep on staging.
osv-scanner / Dependabot The right tool for dependency CVEs. Turn Dependabot on and forget it.
Semgrep Source-level pattern scanning (unescaped sinks, string-built SQL) if you want a second opinion on the code review.
AI pentest agents (XBOW, CAI, PentestGPT) Only extend reach with credentials. On a hardened unauthenticated surface they hit the same 401 wall you do; give them a session and they help confirm the Phase 2 items.

One rule for all of them: the aggressive ones (a full nmap, nuclei, nikto, or a sqlmap run) belong on a staging clone, not fired at a live store, and on shared hosting you keep the scope tight so you are not scanning the whole box. The pattern across that table is the same point I keep making: on a well-configured app, the unauthenticated tools mostly come back clean, and the real signal is behind the login. Which is exactly why the AI-assistant approach fits so well. It is the one "tool" that reads your source to find the missing check and runs the authenticated test to confirm it and writes the fix. Everything else does one of those three.

The full master prompt

Here is the whole method as one job. Paste it into an AI coding assistant that has your repository open (something like Claude Code in your project folder). Fill in the <PLACEHOLDERS> first.

Run it with the most capable model you have access to and the highest reasoning effort setting, and ask it to fan out rather than answer quickly. This is a thoroughness job, not a speed job. In Claude Code that means picking the strongest model and turning effort to its maximum tier; the word "ultracode" in the prompt tells it to decompose the work across parallel sub-agents and verify each finding adversarially instead of doing a single fast pass.

ultracode. Be exhaustive: fan out into parallel sub-agents, verify each finding
adversarially (does the code AND the live behaviour agree?), and favour
thoroughness over speed.

You are helping me, the OWNER, run an authorised security review of my own web
app. Stack: <serverless functions + SQL DB + auth library>. You have this repo,
and I can give you a logged-in session. Rules of engagement, obey exactly:
- Target = <STAGING_URL>, not production. Non-destructive only.
- Before any write test, I will run a DB backup; wait for me to confirm it.
- Use only my two test accounts, <A_EMAIL> and <B_EMAIL>. Never email real users.
  No brute force past ~12 attempts, no floods, no third-party targets. We clean
  up test rows at the end.
- For live requests, give me one-line browser-console fetch() snippets to run and
  I paste back the output, or use curl with a cookie I provide.

Work the review in two phases, and treat a finding as CONFIRMED only when the
SOURCE CODE and the LIVE BEHAVIOUR agree. For every item, quote the relevant
file:line AND the observed response.

PHASE 1 (outside-in, non-destructive): response headers vs the 2026 baseline
(CSP, HSTS, nosniff, XFO/frame-ancestors, Referrer-Policy, Permissions-Policy,
COOP/CORP); TLS floor; content discovery (.git/.env/source maps/config/backups
must 404; security.txt should exist); CORS reflection; TRACE disabled; auth-route
fingerprint by GET only, and for any dependency CVE check whether the vulnerable
feature/route is even mounted before rating it. Run osv-scanner / npm audit /
`list installed auth-lib version` and flag anything unpatched.

PHASE 1b (the layer under the app): identify which services actually answer the
internet (database 3306/5432, cache 6379/11211, FTP 21, admin panels) and flag any
that should not be public — a reachable database or cache is CRITICAL even if fully
patched, because exposure is the finding, not the version. Detect whether a WAF sits
in front (a benign <script>/traversal probe should be blocked, not answered 200). On
shared hosting keep scans scoped to specific ports, never full-range.

IF THIS IS WORDPRESS / AN OFF-THE-SHELF PLATFORM: fingerprint core, theme, and every
plugin version (feed generator, readme.txt Stable tag, asset ?ver=) and check each
against current releases + WPScan/Wordfence/Patchstack. For any plugin whose version
is hidden, tell me to read it from the admin panel. Prioritise plugins that run code,
move money, or write orders, and flag any distributed outside the official directory
as needing manual vendor review. Check user enumeration (wp/v2/users, ?author=1),
xmlrpc.php, readme.html/license.txt version leaks, and login brute-force protection.

PHASE 2 (inside-out, authenticated — the important half). For each, verify in the
source that the server enforces the control, then confirm live with my session:
- BOLA/IDOR (API1): every object route must check the session owns the object.
  Test with User B using User A's IDs.
- BFLA (API5): every privileged/host-only action must re-check role server-side,
  not just hide the button.
- Mass assignment (API3): unknown/privileged fields (role, is_admin, price, tier)
  must be stripped; client-derived fields must be recomputed server-side.
- Stored XSS via client-controlled fields: values sent past the UI must be
  enum-validated server-side and escaped on render. Verify the render CONTEXT
  (a <select> neutralises breakout; a <div> does not) before rating severity.
- Password-reset redirect / trusted-origins: the reset link must only ever point
  to my domain.
- CSRF + session cookie flags (HttpOnly/Secure/SameSite); admin brute-force
  resistance (rate-limit/lockout/CAPTCHA); email + CRLF/header injection on
  free-text fields that reach emails or an admin view; SQL injection by grepping
  for string-built queries (all must use bound parameters); session lifecycle
  (revoke-other-sessions actually kills them; signed-out cookie is dead
  server-side); excessive data exposure on list endpoints.

Calibrate severity to REAL reachability, not pattern-match: a latent sink behind
an approval gate, or a CVE whose plugin is 404, is not a live vuln — say so.
Deliverable: a ranked findings table (most severe first) with, for each,
file:line, the live evidence, severity, and the exact fix. Do NOT change code in
this pass; report only. When I approve, implement the fixes in small reviewable
commits with a regression test for each (especially a BOLA test proving User A
cannot touch User B's objects), running the matching check after each to show it
now passes.

That single prompt runs the whole method. The first pass reports; you read it, decide what is real, and let the second pass fix it with tests. Because the assistant has both your code and your session, its findings come with a file and a line and a reproduction, which is exactly the form a finding needs to be actionable rather than alarming.

What to fix first

When the report comes back, resist doing it in severity order top to bottom. Do it in blast-radius order:

  1. Anything that lets one user reach another user's data or actions (BOLA, BFLA). This is almost always the highest real risk on a multi-user app, and scanners never find it.
  2. Anything that turns a low-privilege user into a high-privilege one (mass-assignment, admin brute-force, reset-token theft).
  3. Stored injection that reaches another user or an operator (the enum-validation and email-escaping items).
  4. Then the header and config hardening, which is real defence-in-depth but rarely the thing that gets you breached on its own.

None of this replaces a professional pentest before you handle serious money or serious data, and it is not meant to. It means that when you do buy one, you are paying an expert to find the subtle third thing, not the twelve obvious things you could have caught for free. That is the whole small-business thesis of this site: the tools and the method are free and run against your own app, no signup and no data leaving your hands, because the gap between "I can't check this myself" and "I can" is now a good prompt and an afternoon.

That do-it-yourself-instead-of-hiring-it-out instinct is the through-line of my book The $20 Dollar Agency, if you want the wider version of this argument. Security is just one more line item you were quoted four figures for that a careful afternoon can knock out.

Fact-check notes and sources

Related reading

This post is informational, not security-consulting advice. Test only systems you own or have explicit written authorisation to test; unauthorised testing is illegal in most jurisdictions. Mentions of third-party tools and standards are nominative fair use and imply no affiliation or endorsement.

← Back to Blog

Accessibility Options

Text Size
High Contrast
Reduce Motion
Reading Guide
Link Highlighting
Accessibility Statement

J.A. Watte is committed to ensuring digital accessibility for people with disabilities. This site conforms to WCAG 2.1 and 2.2 Level AA guidelines.

Measures Taken

  • Semantic HTML with proper heading hierarchy
  • ARIA labels and roles for interactive components
  • Color contrast ratios meeting WCAG AA (4.5:1)
  • Full keyboard navigation support
  • Skip navigation link
  • Visible focus indicators (3:1 contrast)
  • 44px minimum touch/click targets
  • Dark/light theme with system preference detection
  • Responsive design for all devices
  • Reduced motion support (CSS + toggle)
  • Text size customization (14px–20px)
  • Print stylesheet

Feedback

Contact: jwatte.com/contact

Full Accessibility StatementPrivacy Policy

Last updated: April 2026