Security & Best Practices
A plugin runs with the same privileges as core. A mistake in a plugin is a mistake in the site. This page lists the rules that matter most and the patterns that keep a plugin safe.
The ten rules
- Escape all output.
- Validate all input and never trust the context array.
- Never store secrets in the repository.
- Never let a filter change ownership, status, or credentials.
- Make write actions idempotent.
- Use transactions for multi-row writes.
- Gate outbound requests against SSRF.
- Check permissions on every admin route.
- Fail closed for security, open for availability, and log both.
- Keep
boot()free of I/O.
Output escaping
Slots return HTML. If you interpolate user data, escape it.
// Wrong
return '<div>' . $context['name'] . '</div>';
// Right
$name = htmlspecialchars((string) ($context['name'] ?? ''), ENT_QUOTES, 'UTF-8');
return '<div>' . $name . '</div>';
Blade escapes {{ }} automatically:
<div>{{ $name }}</div>
Raw output requires intent:
{!! $trustedHtml !!}
Use {!! !!} only for HTML you generated and sanitized yourself. Never for user input.
URLs
$url = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
Also validate the scheme. Allow http and https; reject javascript:, data:, and file:.
Attributes
Quote attributes and escape them:
return '<a href="' . $url . '" title="' . $title . '">' . $label . '</a>';
An unquoted attribute can break out with a space.
JSON in HTML
Use json_encode() with the right flags, then escape for the HTML context:
$json = json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
Input validation
The context array is not trusted. It comes from core, but a future core change or a bad caller can put anything in it.
$orderId = isset($context['order_id']) ? (int) $context['order_id'] : 0;
if ($orderId <= 0) {
return '';
}
Rules:
- Cast scalars to the expected type.
- Whitelist enum values.
- Reject arrays where a scalar is expected.
- Check array keys with
array_key_exists, notisset, whennullis meaningful. - Limit string length before storing or rendering.
Data filters and the allow-list
Core calls:
plugin_filter_model_data($data, 'job.create_data', $allowed, $context);
The helper:
- Runs your filter.
- Intersects the result with
$allowed. - Re-asserts the original values for protected keys.
You cannot change user_id, status, password, is_hired, or similar fields. If your filter
returns a non-array, the original data is used.
Do not try to bypass this by writing directly to the database from a filter. Use an action after the write, or ask core for a legitimate seam.
Secrets
Never commit:
- API keys.
- Gateway secrets.
- SMTP passwords.
- Webhook signing secrets.
- Private keys.
- OAuth client secrets.
Store them as static options, encrypted config, or environment variables. Mask them in the admin UI:
<input type="password" name="api_key" value="<?php echo $hasKey ? '********' : ''; ?>">
On save, ignore the masked placeholder and keep the existing value.
Logging
Never log:
- Passwords or password hashes.
- OTP codes.
- Session IDs or tokens.
- Full card numbers.
- API keys.
- Personal data you do not need.
Mask the middle of an email or key if you need to identify it:
$masked = substr($email, 0, 2) . '***' . substr($email, strpos($email, '@'));
Capabilities and least privilege
Declare only the capabilities you use:
"capabilities": ["outbound-https"]
Ask for the smallest permission scope. An admin route that only reads should use a -view
permission, not -manage. A user route should check ownership, not just authentication.
$order = Order::findOrFail($orderId);
if ((int) $order->user_id !== (int) auth()->id()) {
abort(403);
}
Authentication proves who you are. Authorization proves you may act on this record.
CSRF and API routes
routes/web.phpis inside thewebgroup, so it has CSRF protection. Keep it.routes/api.phphas no CSRF and no session. Authenticate explicitly and verify signatures.- Never disable CSRF for a browser form.
- For a webhook, use a signature and a timestamp, not a shared session.
SQL injection
Use bindings. Never concatenate input into SQL.
// Wrong
DB::select("select * from my_plugin_items where label = '$label'");
// Right
DB::select('select * from my_plugin_items where label = ?', [$label]);
The query builder and Eloquent bind by default. Raw expressions need care.
File uploads
- Validate the extension and the MIME type.
- Generate the stored filename; never trust the client's.
- Store outside the web root, or in a directory with execution disabled.
- Limit size and count.
- Scan or reject executable types.
- Do not trust the
Content-Typeheader alone.
SSRF
Any plugin that fetches a URL on behalf of a user is an SSRF risk. A user can point it at
http://127.0.0.1:6379 or a cloud metadata endpoint.
Rules:
- Allow only
httpandhttps. - Resolve the hostname and reject private, loopback, link-local, and reserved ranges.
- Re-check after redirects; do not follow redirects blindly.
- Set a short timeout and a response size limit.
- Do not return the raw response body to the user if it may contain internal data.
Private ranges to block:
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
127.0.0.0/8
169.254.0.0/16
::1/128
fc00::/7
fe80::/10
The Webhooks plugin implements this guard. Copy its approach rather than inventing one.
Webhook signing
If you send webhooks:
- Sign the raw body with HMAC-SHA256.
- Include a timestamp and reject old timestamps.
- Use a per-endpoint secret.
- Include a delivery ID for idempotency.
- Retry with backoff.
- Log the response status and a truncated body.
- Never retry a 4xx except 408 and 429.
If you receive webhooks:
- Verify the signature before parsing the body.
- Use
hash_equals()for comparison. - Reject replays by delivery ID.
- Respond 2xx quickly, then process asynchronously.
Idempotency
Actions can fire more than once in edge cases: a retried IPN, a double click, a queue retry.
Make writes idempotent:
DB::table('my_plugin_ledger')->updateOrInsert(
['order_id' => $orderId, 'kind' => 'commission'],
['amount' => $amount, 'updated_at' => now()]
);
Or use a unique index and catch the duplicate:
try {
DB::table('my_plugin_ledger')->insert([...]);
} catch (\Illuminate\Database\QueryException $e) {
// Already recorded.
}
For payments, key on the transaction ID, not the amount and date.
Transactions
Use a transaction when a single logical change touches more than one row:
DB::transaction(function () use ($orderId): void {
DB::table('my_plugin_items')->insert([...]);
DB::table('my_plugin_ledger')->insert([...]);
});
Do not send email or call an external API inside a transaction. Do it after the commit.
Race conditions
Two requests can arrive at once. Use:
- A unique index for uniqueness.
lockForUpdate()for read-modify-write.- Compare-and-set updates for state machines.
- A queue with a single worker for serial work.
Freelancer Level's freelancer.level.changed action uses a compare-and-set write so concurrent
order completions cannot double-fire a level change.
Rate limiting and abuse
- Rate-limit public endpoints.
- Cap input length before calling an AI provider.
- Cap output length before storing.
- Add a cooldown to expensive actions.
- Do not let an unauthenticated request trigger an email or SMS.
AI Power's frontend guardrails are a good reference: rate limit, input cap, output cap, and contact filtering.
Fail open or fail closed
| Area | Behavior | Reason |
|---|---|---|
| Phone OTP | Fail open if the SMS plugin is missing | Otherwise every user is locked out |
| Security scan | Fail open with a log entry | Do not block checkout on a scanner outage |
| Payment verification | Fail closed | Never credit money on an unverified callback |
| Permission check | Fail closed | Deny by default |
| Webhook signature | Fail closed | Reject unsigned requests |
| VIES lookup | Fail open, mark unverified | Do not block profile saves |
Document the choice in the plugin README.
Privacy
- Collect only what you need.
- Tell the operator what you store and where.
- Provide a delete path for user data.
- Do not copy core personal data into a plugin table without a reason.
- Do not send personal data to a third party without the operator's configuration and consent.
- Support log retention and pruning if you write logs.
Internationalization
- Wrap user-facing strings in
__(). - Use the plugin's translation namespace.
- Do not concatenate sentences; use placeholders.
- Format dates and numbers with the site's locale.
- Test right-to-left layouts if the site supports RTL.
__('my-plugin::messages.welcome', ['name' => $name])
Performance
- Do no I/O in
boot(). - Use
plugin_has_filter()in core, not in the plugin, to skip expensive payloads. - Cache expensive computations per request, not globally.
- Paginate large queries.
- Add indexes for columns you filter on.
- Queue slow work.
- Keep slot output small.
- Avoid loading a large JS bundle on every page.
Accessibility and UX
- Use semantic HTML.
- Label every input.
- Keep focus states visible.
- Do not rely on color alone.
- Support keyboard navigation.
- Show errors next to the field.
- Confirm destructive actions.
- Do not auto-play audio.
Security review checklist
- All output escaped.
- All input cast and validated.
- No secrets in the repository.
- No direct writes to core tables.
- Admin routes permission-gated.
- Ownership checks on user routes.
- CSRF preserved on web forms.
- Webhook signatures verified.
- Outbound URLs SSRF-guarded.
- Writes idempotent.
- Multi-row writes in a transaction.
- Logs contain no secrets or OTPs.
- Rate limits on public endpoints.
- Failure behavior documented.
- Dependencies reviewed.
Common vulnerabilities and fixes
| Vulnerability | Example | Fix |
|---|---|---|
| XSS | Slot prints a name unescaped | htmlspecialchars |
| SQL injection | String interpolation in a query | Bindings |
| IDOR | Order route trusts the ID | Check ownership |
| CSRF | Custom form without a token | Keep the web group |
| SSRF | User-supplied webhook URL | Block private ranges |
| Open redirect | redirect($request->input('next')) | Allow-list paths |
| Mass assignment | Model::create($request->all()) | Explicit fields |
| Path traversal | Zip extraction with ../ | Validate entry paths |
| Replay | Webhook accepted twice | Delivery ID + timestamp |
| Race | Double credit | Unique index or lock |
Last updated: October 21, 2026

