Put the Boundary Where the Facts Are
The fix is a backend-for-frontend: a small endpoint in the service that does own the facts, which the browser talks to instead.
It has exactly four jobs:
/**
* Backend-for-frontend for the inline "AI rewrite" button on live sites.
*
* This proxy moves that hop server-side:
* 1. authenticate the short-lived, per-site editing token (this service
* owns that table โ the AI service never could);
* 2. cap cost per site (burst on the route, daily ceiling here);
* 3. compose the prompt server-side;
* 4. call the AI service with the workspace credential in the header,
* never exposing it to the browser.
*/
Note what it is not. It is not a passthrough that forwards the body along. A proxy that relays whatever it is given has moved the credential and kept every other problem. Each of those four jobs is a decision the browser no longer gets to make.
The cost is one hop and one controller. Compare that against the four things in the previous section and it is not a close call.
Credentials Go in Headers, Never in URLs
The token moved from the query string to the Authorization header, and that is worth its own section because it is a habit, not a one-off.
A URL is not a private channel. It lands in web server access logs, in the browser's history and autocomplete, in the Referer header sent to third parties when the page loads an external asset, in analytics, in error reports, and in the chat message where someone pastes a link to show a colleague the bug.
A header lands in none of those by default.
// The editing token is sent in the Authorization header (not the URL),
// so it stays out of logs, history and Referer leakage.
$tokenString = $request->bearerToken();
if (! is_string($tokenString) || $tokenString === '') {
return new ErrorResponse('Missing editing token.', Response::HTTP_UNAUTHORIZED);
}
The same rule applies to anything else identifying: never put a token, a session id, or a signed payload in a path or query string that a browser will render, remember, or forward.
One Guard, One Answer
The lookup is small, and every detail in it is deliberate:
private function resolveSite(?string $tokenString): Site|ErrorResponse
{
if (! is_string($tokenString) || $tokenString === '') {
return new ErrorResponse('Missing editing token.', Response::HTTP_UNAUTHORIZED);
}
$token = Token::query()
->with('site.server.workspace')
->where('id', $tokenString)
->first();
if (! $token || ! $token->expires_at || ! $token->expires_at->isFuture()) {
return new ErrorResponse('Invalid or expired editing token.', Response::HTTP_UNAUTHORIZED);
}
$site = $token->site;
if ($site === null) {
return new ErrorResponse('Invalid or expired editing token.', Response::HTTP_UNAUTHORIZED);
}
return $site;
}
Expiry is checked explicitly, and a missing expiry fails. ! $token->expires_at is not defensive noise โ a row with a null expiry would otherwise be a permanent credential, which is the opposite of what a short-lived token is for. The absence of a limit is treated as invalid rather than as unlimited.
Three different failures give one identical message. Token unknown, token expired, site detached: all "Invalid or expired editing token." Distinguishing them would tell an attacker which tokens exist โ a small oracle, and free to avoid.
It returns a union, not an exception, and not null. Site|ErrorResponse means the caller cannot forget to handle the failure; the type system makes the error path as real as the success path.
The relations are eager-loaded in the guard. with('site.server.workspace') because the very next thing every caller needs is the workspace credential. Authenticating and then lazily walking three relations is how one request becomes four queries.