The admin panel had been at /admin on the customer hostname since 2016, behind the same session cookie, the same rate limit and the same application. A penetration test in August pointed out that a stored cross-site scripting flaw in a product description was, in that arrangement, an admin account takeover.
The symptom
the finding, in one sentence: a script executing on
shop.example can read the session cookie for shop.example,
and the admin panel is on shop.example.
and the four things that followed from the same decision:
one rate limit the admin login shares a zone with a
product page
one WAF ruleset tuned for customer traffic
one session an admin browsing the shop is one
session and one CSRF token
one access log admin activity is invisible in itThe cookie is the finding and the other three are the same decision showing up in different places. None of them is a vulnerability on its own and together they mean the admin surface has the security posture of a product page.
Why it happens
An admin panel starts as a route in the application because that is the smallest thing that works, and it inherits every property of the application it lives in. Separating it is never urgent until somebody frames it as a boundary rather than a section.
The fix
A separate hostname, and therefore a separate cookie
// config/session.php — resolved per request, by host
'cookie' => env('SESSION_COOKIE', 'shop_session'),
'domain' => env('SESSION_DOMAIN'),
// and the middleware that selects it
if ($request->getHost() === config('app.admin_host')) {
config([
'session.cookie' => 'shop_admin_session',
'session.domain' => config('app.admin_host'),
'session.secure' => true,
'session.same_site' => 'strict',
]);
}
A cookie scoped to admin.example.com with no leading dot is not sent to shop.example.com, which is the property that makes the boundary real. SameSite=Strict on the admin cookie is affordable in a way it is not on the customer site, because nobody links into an admin panel from an email.
Setting the session domain to the bare host rather than to a dotted parent is the detail that does the work — .example.com sends the cookie to every subdomain and is what most configurations have, usually without anybody choosing it.
Its own rate limit and its own log
limit_req_zone $binary_remote_addr zone=admin_login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=admin:10m rate=120r/m;
log_format admin '$remote_addr $http_x_forwarded_for [$time_local] '
'"$request" $status $request_time "$http_user_agent"';
server {
server_name admin.example.com;
access_log /var/log/nginx/admin.log admin;
location = /login {
limit_req zone=admin_login burst=3 nodelay;
limit_req_status 429;
}
location / { limit_req zone=admin burst=40 nodelay; }
}
A separate access log is the change that costs nothing and pays back during every investigation — “what did this administrator do on Tuesday” was previously a grep through four hundred million customer requests. Behind a proxy the zone key has to be the forwarded address and the proxy has to be trusted, or the limit applies to the proxy and blocks everybody or nobody.
Network restriction, and why it is not the whole answer
the tempting version:
allow 203.0.113.0/24; # the office
deny all;
and why it lasted three weeks: somebody worked from home,
somebody was at a client site, the on-call person was on a
train, and a remote support engineer joined.
each produced an exception, the exceptions went into the
allow list, and the list became eleven ranges including a
residential ISP block.
what replaced it: mandatory two-factor, with the IP
restriction kept for the two most destructive routes.An allow list that accumulates exceptions is worse than none, because it looks like a control and is a list of eleven ranges nobody has reviewed. Keeping it for a small number of genuinely destructive endpoints — bulk delete, credential rotation — is where it holds, because those are used rarely enough that the exception process is acceptable.
Two-factor as a requirement
// enforced on a capability, not a role — so a customer
// service account with one admin permission is covered
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if ($user?->can('access-admin') && ! $user->hasTwoFactorEnabled()) {
return $request->expectsJson()
? response()->json(['error' => 'two_factor_required'], 403)
: redirect()->route('two-factor.setup');
}
return $next($request);
}
always missing from a first attempt:
recovery codes, shown once, and a way to regenerate
a grace period for existing users, with a hard deadline
an admin reset path — itself the weak point, and it must
be audited and require a second person
a break-glass account, in a safe, used neverThe reset path is both the support requirement and the vulnerability, and requiring a second person to approve it is the only arrangement that resolves the tension. It is a small amount of code and a genuine operational cost — somebody has to be available — which is why it is usually skipped.
An audit log that records reads
// on the admin hostname only, for sensitive resources
AuditLog::record([
'action' => 'customer.viewed',
'subject_id' => $customer->id,
'actor_id' => $actor->id,
'context' => ['reason' => $request->input('reason')],
]);
// a list view of 50 customers is ONE record with 50 ids,
// not 50 records — otherwise the volume makes it useless.
Recording who looked at a customer record is the question that gets asked after an incident and cannot be answered retrospectively. Scoping it to genuinely sensitive resources rather than everything is what makes it affordable, and deciding which those are is a conversation with whoever owns the data rather than a technical judgement.
Verifying it worked
# the assertion that the cookies are separate
$ curl -sI https://shop.example/ | grep -i set-cookie
set-cookie: shop_session=...; path=/; domain=shop.example; HttpOnly
$ curl -sI https://admin.example.com/ | grep -i set-cookie
set-cookie: shop_admin_session=...; path=/; domain=admin.example.com;
Secure; HttpOnly; SameSite=Strict
# and the negative test, which is the one that matters:
# log in as an admin, then browse the shop as a customer
# → two sessions, two cookies, and a script on the shop
# cannot read the admin one
$ ./bin/pentest-retest --finding=XSS-2022-04
no longer exploitable for privilege escalationRe-testing the original finding rather than asserting that the configuration changed is the acceptance criterion, and it needs the person who found it. The cross-site scripting flaw itself was fixed separately — the boundary work means the next one is not an account takeover.
What this costs
Friction for the people who use it most. An administrator browsing the shop to reproduce a customer problem is now two logins, and a support engineer moving between the two loses their place. That is a real daily cost paid by a small number of people to remove a risk that affects everybody, which is a trade worth stating plainly rather than presenting as free.
The two-factor reset path is the other cost and it is an operational one: somebody has to be available to approve a reset, and the first time that happens outside working hours the process will be bypassed. Writing down who can approve and how they are reached is what stops the bypass becoming the procedure.