Security in Joomla Extensions

This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production.

This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages.

Security in a Joomla extension is a short list of habits repeated in every entry point: filter what comes in, escape what goes out, prove the request was intended (token), prove the user is allowed (ACL), never build SQL from strings, treat uploads and outbound URLs as hostile, and ship assets and files in the places Joomla expects. This page collects those habits for the Tallpine Hiking Club package pkg_tallpine (component com_tallpine, namespace Tallpine\Component\Tallpine, module mod_tallpine_nexthikes, plugins plg_task_tallpine_trailsync and plg_webservices_tallpine). It ends with what the 2026 core security advisories teach, because they show the same few mistakes in code written by the core team itself.

The page was written on 2026-10-30 against Joomla 6.2.x (6.2.1 source tree) and PHP 8.3 or later. Joomla 5.4 is in its security-only period (security fixes until 2027-10-12); the APIs below exist there too unless a version note says otherwise. Joomla 4 and 3 are end of life, and extensions that still target them carry the bugs this page describes.

Related pages: the admin-side defaults (MFA, passkeys, HTTP headers, permissions of the install) are in Security hardening, MFA and passkeys; the permission model is in ACL in extensions; query building is in The database API; forms and their filters are in Forms and form fields; and the generic PHP background (hashing, escaping, files) is in PHP: cryptography, hashing and passwords.

The shape of the defences

A request to an extension passes several independent checks. Each one stops a different class of attack, and none of them replaces another: a valid CSRF token says nothing about permission, and a permission check says nothing about whether a string is safe to print.

flowchart TD R[HTTP request] --> G{Entry file loaded through Joomla?<br/>_JEXEC guard} G -- no --> X1[die] G -- yes --> T{Form token valid?<br/>checkToken} T -- no --> X2[Invalid token, redirect] T -- yes --> A{User allowed?<br/>authorise} A -- no --> X3[403 / not permitted] A -- yes --> I[Read input with a filter<br/>Input, InputFilter, form validation] I --> D[Prepared statements<br/>bind, whereIn] D --> O[Escape on output<br/>escape, htmlspecialchars] O --> S[Response with CSP and<br/>assets from /media]

The Developer Manual states the underlying principles in five lines, and they are the best checklist for design reviews: treat every input as untrusted (POST, GET, cookies, $_REQUEST, parts of $_SERVER and HTTP headers); never count client-side checks as security; do not write your own cryptography; do not rely on obscurity; and reduce the attack surface by preferring Joomla’s own classes to custom code.

Keeping files from running outside Joomla: the _JEXEC guard

Every PHP file of an extension that is not an entry point starts with the guard below, directly after the namespace and use statements. The administrator and site entry points define _JEXEC before they load anything, so a file reached by requesting its URL directly stops instead of running with no framework, no session and no access control.

<?php

/**
 * @package     Tallpine.Administrator
 * @subpackage  com_tallpine
 *
 * @copyright   (C) 2026 Tallpine Hiking Club
 * @license     GNU General Public License version 2 or later
 */

namespace Tallpine\Component\Tallpine\Administrator\Controller;

use Joomla\CMS\MVC\Controller\AdminController;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

class TrailsController extends AdminController
{
}

Points to know:

  • Files that only declare a class or a function are the ones that most need it, because the attack is "request the file and exploit what it does when run". A file with no side effects is harmless, but the guard is required by the JED standards and by the coding standard, and costs nothing.

  • JPATH_PLATFORM is no longer defined on a new Joomla 6 install. Extensions written for 3.x and 4.x often use defined('JPATH_PLATFORM') or die;. With the "Behaviour - Backward Compatibility 6" (BC6) plugin disabled, that line makes the page stop with a blank screen. Replace it with defined('_JEXEC') or die;. _JEXEC is the constant core uses everywhere.

  • The guard does not protect the entry files themselves (tallpine.php of legacy components, installer script.php, AJAX endpoints). A legacy component that loads through index.php?option=com_tallpine is always inside Joomla; an extension that ships its own standalone .php URL for AJAX bypasses the framework and must not exist. Use a component task, a module ?option=com_ajax call or a Web Services route instead.

  • Files that must never be reachable (templates of the installer, data files, *.sql) are best kept out of the web root entirely or covered by the web server configuration; see Installation and the public folder.

Reading input safely

The Input object

Controllers, dispatchers and models read request data through an instance of Joomla\Input\Input (the class of the joomla/input framework package). In a controller it is $this->input; elsewhere take it from the application with $app->getInput(). The CMS subclass \Joomla\CMS\Input\Input was removed in 6.0 and is available only through BC6, so type hints and use statements written for 3.x and 4.x must name \Joomla\Input\Input.

<?php

namespace Tallpine\Component\Tallpine\Site\Controller;

use Joomla\CMS\MVC\Controller\BaseController;

\defined('_JEXEC') or die;

class TrailController extends BaseController
{
    public function display($cachable = false, $urlparams = [])
    {
        // Default filter of get() is CMD: letters, digits, underscore, dot, dash
        $layout = $this->input->get('layout', 'default');

        // Typed shortcuts
        $id     = $this->input->getInt('id', 0);          // first integer found, may be negative
        $limit  = $this->input->getUint('limit', 20);     // unsigned integer
        $search = $this->input->getString('q', '');       // decoded and stripped of tags
        $season = $this->input->get('season', [], 'array');

        // Per-method access: only a value really sent in the query string
        $fromGet  = $this->input->get->getInt('id', 0);
        $fromPost = $this->input->post->getString('title', '');

        // The request body of an API call sent as application/json
        $payload = $this->input->json->getArray();

        return parent::display($cachable, $urlparams);
    }
}

What the Manual’s chapter and the code establish:

  • get($name, $default, $filter) defaults to the CMD filter, so a missing filter is a conservative choice. getArray() defaults to STRING for every element.

  • $input->get, $input->post, $input->files, $input->json and $input->server are sub-inputs bound to a single source. The Manual points out that the combined $input->get() also contains routing parameters set by SEF parsing, while $input->get->get() and $input->post->get() do not. Read post for state-changing data so a link cannot supply it.

  • set() always writes a value into the input, and def() writes only if the parameter is absent. Writing a default with def() is a common source of surprising "the request said otherwise" bugs.

The filters

The filter name is the third argument of get() or the name of a typed shortcut. These are the names, as described by the Developer Manual and implemented by the joomla/filter package that Joomla\CMS\Filter\InputFilter extends:

Filter What it returns Use for

INT, INTEGER

The first integer found in the value (can be negative). Arrays are cast element by element.

Identifiers from a trusted range check, cid[] arrays

UINT

An unsigned integer: the sign is discarded

Primary keys, page numbers, limits

FLOAT, DOUBLE

The first floating-point number

Coordinates, distances

BOOL, BOOLEAN

A boolean. Any non-empty string, including "false", becomes true

Avoid for text input. Use getInt() with 0 or 1

WORD

Letters and underscore only

Identifiers of fixed vocabularies

ALNUM

Letters and digits only

Tokens, short codes

CMD

Letters, digits, underscore, dot and dash; leading dots are removed

Task, view, layout and format names (the default)

BASE64

Letters, digits, /, + and =. You must decode it yourself

Data that is really base64

STRING

Decodes HTML entities, then strips all tags

Plain-text fields (a title, a search term)

HTML

Strips tags with a whitelist, without decoding entities first

Rich text, then still escape or run the text filters

ARRAY

No filtering, only the cast to an array

Collections, then filter each element

PATH

A relative file or directory path. Absolute paths and trailing slashes yield an empty string

File names below a known folder

USERNAME

Removes characters not allowed in a Joomla user name

Do not use for authentication decisions

RAW

No filtering at all

Only when the value is handled by something else, such as password_verify()

Pitfalls specific to the filters:

  • A filter is not validation. getInt('id') turns "7 UNION SELECT ..." into 7 and "abc" into 0; neither is an error. After filtering, check the range or existence (if ($id < 1) throw new \InvalidArgumentException(...)).

  • STRING removes markup; it does not make the result safe in every context. Text that reaches an attribute, a JavaScript string or a URL still needs the right escaping on output (see below).

  • RAW is a decision, not a default. Reading the request raw is correct for a password the code hashes or an upload descriptor passed to the upload helpers; it is wrong for anything that is later printed or stored for printing.

  • Filter arrays element by element. get('cid', [], 'array') returns whatever the client sent. Prefer get('cid', [], 'int'), which casts every element, then drop the zeros with array_filter().

  • Names in the SEF path are input too. The router puts id, catid and view into the input; the id of a SEF segment 7-lake-trail becomes 7 through the router, but the same value reaching getString() would not.

InputFilter directly

When the data does not come from the request (a value from a remote API, a configuration string, a field the code is about to store), use the filter without an Input object. Joomla\CMS\Filter\InputFilter::clean() takes the same type names as the table above. The constructor takes an allow-list or block-list of tags and attributes; the constants ONLY_ALLOW_DEFINED_TAGS and BLOCK_DEFINED_TAGS (and the attribute counterparts) choose the mode, and $xssAuto controls whether the filter also cleans tags and attributes that are not blocked.

<?php

namespace Tallpine\Component\Tallpine\Administrator\Helper;

use Joomla\CMS\Filter\InputFilter;

\defined('_JEXEC') or die;

final class TrailTextHelper
{
    /**
     * A plain-text summary: tags removed, entities decoded, then trimmed.
     */
    public static function plain(string $value): string
    {
        return trim(InputFilter::getInstance()->clean($value, 'string'));
    }

    /**
     * A short HTML note written by a trail warden. Only these tags and no attributes survive.
     */
    public static function note(string $html): string
    {
        $filter = InputFilter::getInstance(
            ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li'],
            [],
            InputFilter::ONLY_ALLOW_DEFINED_TAGS,
            InputFilter::ONLY_ALLOW_DEFINED_ATTRIBUTES
        );

        return $filter->clean($html, 'html');
    }
}

Two things matter beyond the call itself:

  • The filter is a safety net with a history of bypasses. In 2026 the Security Centre published two advisories on InputFilter itself: an XSS bypass through whitespace inside HTML data: URIs, and an XSS bypass caused by a mismatch in how HTML5 character references are decoded. Both were fixed in 5.4.9 and 6.1.4. The lesson is not to stop using it but to stack defences: filter on input and escape on output, so a bypass of one layer does not become a stored XSS.

  • Rich text has its own configuration. Fields declared with filter="JComponentHelper::filterText" (or ComponentHelper::filterText($text) in code) apply the Text Filters configured in the component Options for the current user’s groups, which is how Joomla lets Super Users enter markup that Registered users cannot. Use that function for any rich-text field instead of a private allow-list; see Global configuration for the screen.

Filtering through the form

Fields declared in a form XML file are filtered and validated by the Form class when the model calls validate(); anything not declared in the XML is dropped. Setting the right filter and validate attributes in the XML is the best place to keep input rules, because the administrator form, the front-end form and the API all share them.

<?xml version="1.0" encoding="UTF-8"?>
<form>
    <fieldset name="details">
        <field name="title" type="text" label="COM_TALLPINE_FIELD_TITLE"
               required="true" maxlength="255" filter="string" />
        <field name="distance_km" type="number" label="COM_TALLPINE_FIELD_DISTANCE"
               min="0" max="500" step="0.1" filter="float" validate="number" />
        <field name="difficulty" type="list" label="COM_TALLPINE_FIELD_DIFFICULTY"
               default="easy" filter="cmd">
            <option value="easy">COM_TALLPINE_DIFFICULTY_EASY</option>
            <option value="moderate">COM_TALLPINE_DIFFICULTY_MODERATE</option>
            <option value="hard">COM_TALLPINE_DIFFICULTY_HARD</option>
        </field>
        <field name="description" type="editor" label="COM_TALLPINE_FIELD_DESCRIPTION"
               filter="JComponentHelper::filterText" buttons="true" />
    </fieldset>
</form>

Source: Joomla Manual: Forms and validations (pointing to the server-side validation chapter)

A list field’s filter="cmd" does not stop a client from sending difficulty=nonsense; add validate="options" (or check against a set in the model) to reject values the list does not offer. The full set of rules and filters is in Forms and form fields.

Escaping output

Escaping is the other half of XSS defence, and it is the half that does not depend on a filter being perfect. Escape at the moment of output, for the context in which it lands. The Manual calls XSS the most common issue in the extension ecosystem.

In views and layouts

Joomla\CMS\MVC\View\HtmlView::escape() calls htmlspecialchars($var, ENT_QUOTES, charset); it returns '' for null. Since Joomla 4.0 the flag is ENT_QUOTES, so single quotes are escaped too (3.x used ENT_COMPAT), which makes escape() safe inside attributes delimited by either quote.

<?php

\defined('_JEXEC') or die;

use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;

/** @var \Tallpine\Component\Tallpine\Site\View\Trail\HtmlView $this */
$trail = $this->item;
?>
<article class="trail">
    <h2><?php echo $this->escape($trail->title); ?></h2>

    <p class="trail-meta">
        <?php echo Text::sprintf(
            'COM_TALLPINE_TRAIL_META',
            (float) $trail->distance_km,
            (int) $trail->elevation_gain
        ); ?>
    </p>

    <!-- Attribute context: escape() uses ENT_QUOTES, so both quote styles are safe -->
    <a href="<?php echo Route::_('index.php?option=com_tallpine&view=trail&id=' . (int) $trail->id); ?>"
       title="<?php echo $this->escape($trail->title); ?>">
        <?php echo Text::_('COM_TALLPINE_READ_MORE'); ?>
    </a>

    <!-- Rich text from an editor field: already filtered on input, rendered through the content plugins -->
    <div class="trail-description">
        <?php echo HTMLHelper::_('content.prepare', $trail->description, '', 'com_tallpine.trail'); ?>
    </div>
</article>

Rules that the sample follows:

  • Cast numbers. A value printed as a number is cast with (int) or (float) instead of escaped; it cannot contain markup after the cast.

  • Only trusted markup is echoed raw. The article body goes through content.prepare (the content plugins), but it was filtered with the Text Filters when saved. A value that came from the database but was originally typed by a visitor is not trusted merely because it is in the database.

  • Language strings. Text::_() returns the language string as written. If a string contains a placeholder filled with user data, Text::sprintf() does not escape the argument; escape the argument first when the result goes to HTML.

  • Build URLs with Route::_() and cast IDs. Do not concatenate unfiltered query parameters into links.

Outside views: modules, plugins, templates and JavaScript

Modules, plugin layouts and ad-hoc helpers have no escape() method. Use htmlspecialchars() with the same arguments:

<?php

\defined('_JEXEC') or die;

/** @var array $hikes provided by the dispatcher of mod_tallpine_nexthikes */
foreach ($hikes as $hike) :
    ?>
    <li>
        <a href="<?php echo htmlspecialchars($hike->url, ENT_QUOTES, 'UTF-8'); ?>">
            <?php echo htmlspecialchars($hike->title, ENT_QUOTES, 'UTF-8'); ?>
        </a>
        <time datetime="<?php echo htmlspecialchars($hike->date, ENT_QUOTES, 'UTF-8'); ?>">
            <?php echo htmlspecialchars($hike->dateLabel, ENT_QUOTES, 'UTF-8'); ?>
        </time>
    </li>
<?php endforeach; ?>

Source: Joomla Manual: Common vulnerabilities (Cross-Site-Scripting: htmlspecialchars($value, ENT_QUOTES, 'UTF-8') outside component views)

The JavaScript context is different from HTML. Never write var title = '<?php echo $trail->title; ?>'; in a layout: a quote or </script> in the title breaks out. Pass data to scripts as script options (the document serialises them as JSON in a <script type="application/json"> block, which needs no inline code) and read them from JavaScript:

<?php

\defined('_JEXEC') or die;

$wa = $this->getDocument()->getWebAssetManager();
$wa->useScript('com_tallpine.trail-map');

$this->getDocument()->addScriptOptions('com_tallpine.trail', [
    'id'     => (int) $this->item->id,
    'title'  => $this->item->title,       // serialised as JSON, no manual quoting
    'lat'    => (float) $this->item->lat,
    'lng'    => (float) $this->item->lng,
]);
// media/com_tallpine/js/trail-map.js
const options = Joomla.getOptions('com_tallpine.trail');

// Text from the server goes in with textContent, never innerHTML
document.querySelector('.trail-map-title').textContent = options.title;

In JavaScript, assigning to textContent (or creating nodes with createElement) is the equivalent of escape(); innerHTML with data from the server is the equivalent of echo without escaping.

Where core itself got it wrong

Several of the 2026 core advisories are plain output-escaping omissions: XSS in the module list, in the link toolbar layout, in generic media output layouts, in HTMLHelper::link(), in schema.org outputs and in HTML mail templates. None needed an exotic payload; a value the author considered trusted (a title, a link, a file name) was printed in a context nobody had thought of. The practical rule is escape every dynamic value at the point of output, including the ones that "can only be set by an administrator", because the administrator account is exactly what a stored XSS attacks.

Proving the request was intended: CSRF tokens

Cross-site request forgery makes a logged-in user’s browser submit a request the user never meant to send. Joomla’s defence is a per-session token: the form contains a hidden field whose name is derived from the session, so a page on another site cannot guess it. The Manual’s example attack forges a user.add request to the administrator.

Form and controller

<?php

\defined('_JEXEC') or die;

use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
?>
<form action="<?php echo Route::_('index.php?option=com_tallpine&task=report.submit'); ?>"
      method="post" name="adminForm" id="trail-report-form" enctype="multipart/form-data">
    <?php echo $this->form->renderFieldset('details'); ?>

    <button type="submit" class="btn btn-primary"><?php echo Text::_('COM_TALLPINE_SUBMIT'); ?></button>

    <input type="hidden" name="task" value="report.submit">
    <?php echo HTMLHelper::_('form.token'); ?>
</form>

HTMLHelper::_('form.token') outputs <input type="hidden" name="<token>" value="1">; the name is Session::getFormToken(), an application hash of the user ID and the session token. Every form that changes state needs it, including the toolbar-driven list forms of the administrator and any "delete" or "publish" link built as a GET (prefer POST).

<?php

namespace Tallpine\Component\Tallpine\Site\Controller;

use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\FormController;
use Joomla\CMS\Session\Session;

\defined('_JEXEC') or die;

class ReportController extends FormController
{
    public function submit(): bool
    {
        // 1. CSRF: first line of every task that changes data
        $this->checkToken();

        // 2. Permission: a valid token proves intent, not authority
        if (!$this->app->getIdentity()->authorise('core.create', 'com_tallpine')) {
            throw new \Joomla\CMS\Access\Exception\NotAllowed(Text::_('JERROR_ALERTNOAUTHOR'), 403);
        }

        // 3. Input with filters
        $data = $this->input->post->get('jform', [], 'array');

        // ... model->validate(), model->save() ...

        $this->setRedirect(
            \Joomla\CMS\Router\Route::_('index.php?option=com_tallpine&view=trails', false),
            Text::_('COM_TALLPINE_REPORT_SAVED')
        );

        return true;
    }

    /**
     * A task that is not a controller method of its own, e.g. in a plugin or a helper:
     */
    public static function tokenOk(): bool
    {
        return Session::checkToken('post');
    }
}

What the code in core does, so you know what you are relying on:

  • BaseController::checkToken($method = 'post', $redirect = true) calls Session::checkToken($method). On failure it queues the message The most recent request was denied because it had an invalid security token and redirects to the HTTP referrer only if that referrer is an internal URL (Uri::isInternal()), otherwise to index.php. Pass $redirect = false to get a boolean and decide yourself (an AJAX task).

  • Session::checkToken($method) first compares the request header X-CSRF-Token with the expected token; if it matches, the check passes. Otherwise it looks for the token as a field name in the chosen request method (post, get or request). When the session is new (expired), it redirects to the front page with a "session expired" message instead of returning false.

  • The token is bound to the session and the user, so it changes after login and logout. A page cached with a token inside it (page cache, a CDN, a full-page cache plugin) serves other visitors a stale token. Forms with tokens must be excluded from caching; see Caching and performance.

AJAX and fetch

For JavaScript requests, ask the document to expose the token and send it in the header that Session::checkToken() recognises, so the body stays free of a token field.

<?php

\defined('_JEXEC') or die;

use Joomla\CMS\HTML\HTMLHelper;

// Adds the token to the script options under "csrf.token"
HTMLHelper::_('form.csrf');
$this->getDocument()->getWebAssetManager()->useScript('com_tallpine.condition-report');
// media/com_tallpine/js/condition-report.js
const token = Joomla.getOptions('csrf.token');

async function reportCondition(trailId, condition) {
  const response = await fetch('index.php?option=com_tallpine&task=conditions.report&format=json', {
    method: 'POST',
    headers: {
      'X-CSRF-Token': token,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ id: trailId, condition }),
  });

  return response.json();
}

On the server side, a JSON task reads the token with $this->checkToken('post', false) and returns an error response on failure. The Manual’s CSRF page does not cover AJAX; the behaviour above is read from the source.

What a token does not do

The Manual’s key sentence is "CSRF tokens are unrelated from permission checks". A valid token proves only that the form was rendered for this session. It does not prove that the user is logged in, nor that they may do the action; it protects nobody if the same task is also reachable by GET, and it protects nothing against XSS (a script on your own page can read the token). The Web Services API authenticates by bearer token, not by session, and so does not use form tokens; it needs the ACL checks in the next section even more.

Proving the user is allowed: ACL in every task

The most repeated finding in the 2026 core advisories is a missing or inconsistent permission check: webservice edit tasks, mutating webservice endpoints, category, custom field and access-level endpoints, workflow stage changes, batch copy, tagged items, the content history comparison view and schema.org contact data. Each was an endpoint that did the work the controller advertised for anyone who could reach it.

The rule that avoids this class of bug: every task and endpoint that reads protected data or changes anything calls authorise() on the right asset before it does the work, even when the layout hides the button for unauthorised users. Hiding a button is a courtesy, not a check.

<?php

namespace Tallpine\Component\Tallpine\Administrator\Controller;

use Joomla\CMS\Access\Exception\NotAllowed;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\FormController;

\defined('_JEXEC') or die;

class TrailController extends FormController
{
    /**
     * Who may add a trail: a global permission, or core.create in at least one category.
     */
    protected function allowAdd($data = [])
    {
        $user = $this->app->getIdentity();

        return $user->authorise('core.create', 'com_tallpine')
            || \count($user->getAuthorisedCategories('com_tallpine', 'core.create'));
    }

    /**
     * Who may edit this particular trail: core.edit on the item, or core.edit.own on one's own item.
     */
    protected function allowEdit($data = [], $key = 'id')
    {
        $recordId = (int) ($data[$key] ?? 0);
        $user     = $this->app->getIdentity();

        if (!$recordId) {
            return parent::allowEdit($data, $key);
        }

        $asset = 'com_tallpine.trail.' . $recordId;

        if ($user->authorise('core.edit', $asset)) {
            return true;
        }

        if ($user->authorise('core.edit.own', $asset)) {
            $owner = (int) ($data['created_by'] ?? 0);

            if ($owner === 0) {
                $owner = (int) $this->getModel()->getItem($recordId)->created_by;
            }

            return $owner === (int) $user->id;
        }

        return false;
    }

    /**
     * A custom task: publish a trail report. Token first, then the permission on the item.
     */
    public function publishReport(): void
    {
        $this->checkToken();

        $id = $this->input->post->getInt('id', 0);

        if (!$this->app->getIdentity()->authorise('core.edit.state', 'com_tallpine.trail.' . $id)) {
            throw new NotAllowed(Text::_('JLIB_APPLICATION_ERROR_EDITSTATE_NOT_PERMITTED'), 403);
        }

        // ... model->publish([$id], 1) ...
    }
}

The pitfalls are in the details:

  • Check against the item, not only the component. core.edit on com_tallpine is not the same as on com_tallpine.trail.7. A category-level rule that denies the edit must be honoured; checking only the component lets users edit what an administrator excluded.

  • Check state-changing tasks that are not CRUD. Batch operations, ordering (saveOrderAjax), checkin, feature toggles and "copy" actions were each the subject of an advisory. Each calls its own authorise() for the target too: copying an item into a category requires core.create there.

  • Check the data returned, not only the action. A list that is filtered by the viewing access level must use the user’s authorised levels (getAuthorisedViewLevels()) in the query, as shown in ACL in extensions; an output that joins a table (tags, contact data in schema.org) must apply the same filter as the table it joins.

  • Do not trust IDs from the request. An ID in a POST is a claim. Load the item and check the loaded item’s category and owner.

  • Use getIdentity(). The 6.x way to reach the current user is the application identity; Factory::getUser() is a legacy alias and JFactory::getUser() works only with BC6.

  • A global Super User bypasses everything by design. Test with an ordinary account (a Trail Warden group member and a Registered user), not as the Super User, or the permission code is never exercised.

Web Services endpoints

An API controller extends Joomla\CMS\MVC\Controller\ApiController, which already calls allowEdit(), allowAdd() and the delete check before the corresponding action, and those use core.manage first and then core.edit, core.create, core.delete on the component or item. If you override a method or add a custom route, you take on responsibility for repeating the check:

<?php

namespace Tallpine\Component\Tallpine\Api\Controller;

use Joomla\CMS\MVC\Controller\ApiController;

\defined('_JEXEC') or die;

class TrailsController extends ApiController
{
    protected $contentType = 'trails';
    protected $default_view = 'trails';

    /**
     * Custom endpoint: PATCH /api/index.php/v1/tallpine/trails/:id/close
     */
    public function close(): static
    {
        $id = $this->input->getInt('id', 0);

        // The base class calls this for edit(); a custom method must call it itself.
        if (!$this->allowEdit(['id' => $id], 'id')) {
            throw new \Joomla\CMS\Access\Exception\NotAllowed('You are not allowed to close this trail', 403);
        }

        // ... model->close($id) ...

        return $this;
    }
}

The mechanics of routes, tokens and plugins are in Extending the Web Services API and The Web Services API. On fresh 6.2 installs the API Token authentication plugin is enabled and the Basic Authentication API plugin is disabled; keep it that way unless a client really requires Basic authentication.

Talking to the database

Never build SQL by concatenating request data. The Manual calls prepared statements the gold standard: the query and the values travel to the server separately, so a value cannot change the query’s structure.

<?php

namespace Tallpine\Component\Tallpine\Site\Model;

use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\ParameterType;

\defined('_JEXEC') or die;

class TrailsModel extends ListModel
{
    protected function getListQuery()
    {
        $db    = $this->getDatabase();
        $query = $db->createQuery()
            ->select($db->quoteName(['a.id', 'a.title', 'a.distance_km', 'a.difficulty']))
            ->from($db->quoteName('#__tallpine_trails', 'a'))
            ->where($db->quoteName('a.published') . ' = 1');

        // Values: bind() with an explicit type
        $difficulty = $this->getState('filter.difficulty');

        if ($difficulty !== '' && $difficulty !== null) {
            $query->where($db->quoteName('a.difficulty') . ' = :difficulty')
                ->bind(':difficulty', $difficulty);
        }

        // Lists: whereIn() with an explicit type
        $levels = $this->getCurrentUser()->getAuthorisedViewLevels();
        $query->whereIn($db->quoteName('a.access'), $levels, ParameterType::INTEGER);

        // Search: put the wildcards into the bound value, and escape the user's own % and _
        $search = $this->getState('filter.search');

        if ($search) {
            $like = '%' . $db->escape($search, true) . '%';
            $query->where($db->quoteName('a.title') . ' LIKE :search')
                ->bind(':search', $like);
        }

        // Identifiers: never from the request. Whitelist the ordering
        $allowed  = ['a.title', 'a.distance_km', 'a.id'];
        $orderCol = \in_array($this->getState('list.ordering'), $allowed, true)
            ? $this->getState('list.ordering') : 'a.title';
        $orderDir = strtoupper((string) $this->getState('list.direction')) === 'DESC' ? 'DESC' : 'ASC';
        $query->order($db->quoteName($orderCol) . ' ' . $orderDir);

        return $query;
    }
}

Key points (the full treatment, including transactions and the MySQL/PostgreSQL differences, is in The database API):

  • bind() replaces the old quote() calls for values; quoteName() is for identifiers (table and column names), never for values.

  • bind() takes the value by reference. Binding a loop variable and then changing it affects every placeholder bound to it; use bindArray() or whereIn() for lists.

  • Identifiers cannot be bound. Sort columns, table names and field names from a request go through a whitelist, as the sample does (filter_fields in a ListModel is the same mechanism).

  • The query builder does not make raw install SQL portable; the MySQL and PostgreSQL install scripts remain separate files.

  • Get the database object from getDatabase() (the DatabaseAwareTrait) or from the container. Factory::getDbo() is a deprecated legacy call.

File uploads

An upload is attacker-controlled bytes, name, size and claimed type arriving in your PHP process. The Manual lists the risks: executable file types, an attacker choosing the storage path, and large uploads filling the disk. The Tallpine "gpx-file" field accepts a GPS track from a member; the sample below shows the checks the core helpers support.

<?php

namespace Tallpine\Component\Tallpine\Site\Model;

use Joomla\CMS\Filter\InputFilter;
use Joomla\CMS\Helper\MediaHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Path;

\defined('_JEXEC') or die;

class UploadModel extends BaseDatabaseModel
{
    /**
     * Stores a GPX file below images/tallpine and returns the relative path.
     *
     * @param   array  $file  One entry of $input->files->get('jform', [], 'array')
     */
    public function storeGpx(array $file, int $trailId): string
    {
        // 1. Reject executable extensions, null bytes and PHP tags anywhere in the file
        if (!InputFilter::isSafeFile($file)) {
            throw new \RuntimeException(Text::_('COM_TALLPINE_UPLOAD_UNSAFE'));
        }

        // 2. Extension allow-list, size limit and MIME check from the component options
        $helper = new MediaHelper();

        if (!$helper->canUpload($file, 'com_tallpine', ['gpx'])) {
            throw new \RuntimeException(Text::_('COM_TALLPINE_UPLOAD_REJECTED'));
        }

        // 3. The server chooses the name: never use the client's path or full name
        $name   = 'trail-' . $trailId . '-' . bin2hex(random_bytes(6)) . '.gpx';
        $folder = Path::clean(JPATH_ROOT . '/images/tallpine');
        $target = $folder . '/' . $name;

        // 4. Stay inside the folder
        if (strpos(Path::clean($target), $folder) !== 0) {
            throw new \RuntimeException(Text::_('COM_TALLPINE_UPLOAD_PATH'));
        }

        if (!File::upload($file['tmp_name'], $target)) {
            throw new \RuntimeException(Text::_('COM_TALLPINE_UPLOAD_FAILED'));
        }

        return 'images/tallpine/' . $name;
    }
}

Source: Joomla Manual: Common vulnerabilities (Unrestricted file uploads, MediaHelper::canUpload)

What the helpers check, read from the 6.2.1 source:

  • InputFilter::isSafeFile($file) scans an uploaded file descriptor (including nested ones from array-style form names) for a null byte in the name, a forbidden extension anywhere in the name (so shell.php.jpg and shell.jpg.php are both caught), a <?php tag, short tags in the extensions where they matter, the __HALT_COMPILER() PHAR stub, and .php in the content of archive types. The forbidden list (FORBIDDEN_FILE_EXTENSIONS) includes php and its numbered variants, phar, phtml, asp, cgi, py, pl and the server-side-include extensions sht, shtm, shtml and stm.

  • MediaHelper::canUpload($file, $component, $allowedExecutables) requires a safe name (File::makeSafe() must not change it), at least one extension, no executable extension buried anywhere in the name (the built-in list also contains js, html, htm, exe and scripting-host types), an extension in the component’s allow-list, the maximum size, and a MIME type that matches. The defaults read from the component options are restrict_uploads (on), restrict_uploads_extensions (images, audio and video types, pdf, office types, txt, csv), upload_maxsize and ignore_extensions. The third argument lets an extension permit one otherwise-blocked type deliberately (as the gpx example does for a type not in the default allow-list; add gpx to the component’s allowed extensions too).

  • Allow-list, never block-list. A 2026 advisory (fixed in 5.4.8 and 6.1.3) was about .shtml files being accepted because they were missing from the extension block-list: the server could execute server-side includes in them. The allow-list makes that class of omission harmless.

Beyond the helpers:

  • Never trust the client’s MIME type or file name. $file['type'] is whatever the browser sent. Generate the stored name yourself.

  • Keep uploaded files that are not meant to be public outside the web root, or in a folder where the web server is configured to serve static files only and to execute no scripts (see Web server, reverse proxy and HTTPS), and serve them through a controller that checks ACL.

  • SVG is code. An SVG can contain <script> and event attributes. Core’s MediaHelper runs uploaded SVG files through an SVG sanitiser (enshrined\svgSanitize); an extension that accepts SVG must do the same or refuse the type.

  • Limit sizes in three places: the PHP upload_max_filesize and post_max_size, the web server, and the component option. An empty tmp_name after a large upload means PHP discarded it.

  • Use the Media Manager for editors. Fields of type media store a path to a file the Media Manager already vetted; see Media Manager.

  • Joomla\CMS\Filesystem\* is gone from the default install. File operations use Joomla\Filesystem\File, Folder and Path from the framework package; the old CMS wrappers are available only through BC6 and are slated for removal in 7.0.

Outbound requests and SSRF

Server-side request forgery turns your server into the attacker’s browser inside your network. It appears whenever an extension fetches a URL that a user, a form field, a feed, a database row or a remote document can influence: a feed reader, a "fetch metadata from this link" button, an image-by-URL importer, a webhook. The 2026 SSRF advisory, rated High and fixed in 5.4.9 and 6.1.4, covered "various core extensions" for exactly this reason: several places that fetched a user-supplied URL did not restrict where it could point.

The Tallpine task plugin plg_task_tallpine_trailsync fetches trail closures from a fictitious park-authority API, whose URL is a plugin parameter that an administrator can change. The sample treats that URL as untrusted anyway.

<?php

namespace Tallpine\Plugin\Task\TallpineTrailsync\Extension;

use Joomla\Http\HttpFactory;

\defined('_JEXEC') or die;

final class RemoteFetcher
{
    /** Hosts the club agreed to trust. Everything else is refused. */
    private const ALLOWED_HOSTS = ['api.parks.example'];

    public function fetchJson(string $url): array
    {
        $parts = parse_url($url);

        // Scheme and host allow-list: https only, a known host, no credentials in the URL
        if (
            !\is_array($parts)
            || ($parts['scheme'] ?? '') !== 'https'
            || !\in_array($parts['host'] ?? '', self::ALLOWED_HOSTS, true)
            || isset($parts['user'])
        ) {
            throw new \InvalidArgumentException('URL is not allowed');
        }

        // Resolve and reject private, loopback and link-local addresses (DNS may point inward)
        foreach (gethostbynamel($parts['host']) ?: [] as $ip) {
            if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                throw new \InvalidArgumentException('Address is not public');
            }
        }

        // Short timeout, and no automatic redirects to somewhere else
        $http     = (new HttpFactory())->getHttp(['timeout' => 10, 'follow_location' => false]);
        $response = $http->get($url, ['Accept' => 'application/json']);

        if ($response->getStatusCode() !== 200) {
            throw new \RuntimeException('Unexpected status ' . $response->getStatusCode());
        }

        $data = json_decode((string) $response->getBody(), true, 8, JSON_THROW_ON_ERROR);

        return \is_array($data) ? $data : [];
    }
}

Notes:

  • Prefer an allow-list to a deny-list of addresses. The resolved-address check above is a second layer; a DNS answer can change between the check and the request ("DNS rebinding"), so for high-risk features pin the resolved address, or fetch through an egress proxy that forbids internal ranges.

  • Do not follow redirects blindly. A redirect from an allowed host to http://169.254.169.254/ (a cloud metadata service) is the classic bypass. The transport option name for this is read from the joomla/http package; re-check it against the transport in use, and re-run the host check on any redirect target you decide to follow.

  • Set a timeout and a size limit. A slow response keeps a PHP worker; an unbounded body fills memory.

  • Use the framework class. \Joomla\CMS\Http\HttpFactory is deprecated since 6.0 for removal in 8.0; use \Joomla\Http\HttpFactory, which core’s updater also uses.

  • Never return fetched error bodies or headers to the browser. They disclose internal services.

Redirects

An "open redirect" lets an attacker put a link on your domain that sends the visitor elsewhere, which makes phishing look legitimate. If a redirect target comes from the request (a return parameter after login or after saving), validate it:

<?php

use Joomla\CMS\Uri\Uri;

// $return is a base64url value produced by the form, as com_users does
$return = base64_decode($this->input->post->getBase64('return', ''), true) ?: '';

if (!$return || !Uri::isInternal($return)) {
    $return = 'index.php?option=com_tallpine&view=trails';
}

$this->setRedirect($return);

Uri::isInternal() rejects URLs containing quotes, spaces or angle brackets, converts backslashes to slashes, and accepts only URLs that start with index.php or that point at the site’s own base. The BaseController::checkToken() failure path uses the same function before redirecting to the referrer.

Assets, the /media folder and Content Security Policy

JavaScript and CSS of an extension belong in media/com_tallpine/ (installed by the <media> element of the manifest), registered in joomla.asset.json and loaded through the Web Asset Manager. This is a security rule as much as a performance rule:

  • No inline scripts or styles. Inline <script> blocks and style="..." attributes defeat a Content Security Policy that forbids unsafe-inline. Files under /media can be allowed by source ('self'); data goes in as script options.

  • The nonce is added for you. When the HTTP Headers system plugin has a CSP with a nonce enabled, scripts that go through the document renderers receive a nonce attribute (ScriptsRenderer adds it whenever the document has one). An extension that echoes its own <script> tag does not get one and will be blocked.

  • Do not load third-party assets from CDNs by default. Each external origin must be listed in the policy, and a compromised CDN can run code on every page. Ship the file in /media or document the origin.

  • Do not put code or secrets in /media. Everything in it is public by design. The folder belongs to static files; PHP in it would be served as source or executed depending on the server.

  • Version the files with the asset’s version so a security fix to your JavaScript reaches browsers ("version": "auto" uses a hash; 6.1 added "version": "nocache").

The administrator side of the policy (Report-Only mode, directives, nonce) is covered in Security hardening, MFA and passkeys, and the asset registries in The Web Asset Manager.

{
  "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
  "name": "com_tallpine",
  "version": "1.4.0",
  "assets": [
    {
      "name": "com_tallpine.trail-map",
      "type": "script",
      "uri": "com_tallpine/trail-map.min.js",
      "attributes": { "defer": true },
      "dependencies": ["core"]
    },
    {
      "name": "com_tallpine.condition-report",
      "type": "script",
      "uri": "com_tallpine/condition-report.min.js",
      "attributes": { "defer": true },
      "dependencies": ["core"]
    }
  ]
}

Secrets, passwords and cryptography

  • Do not write your own cryptography. The Manual recommends established libraries such as PHP’s libsodium functions. For passwords, never store or compare them yourself: use UserHelper (hashPassword() and verifyPassword()) or password_hash(). See PHP: cryptography, hashing and passwords.

  • Random values. Use random_bytes() for tokens and file names; never rand(), mt_rand(), uniqid() or time().

  • Credentials for remote services (the park-authority API key, an SMTP password) go in a plugin or component parameter declared as a password field, or better in the environment and read at runtime. Never hard-code them, never write them into configuration.php snippets in documentation, never log them.

  • configuration.php is code. Its secret, database password and mail credentials never go into a repository. Extension code reads values through $app->get('...'), never from the file.

  • Do not log personal data or tokens in error.php and the action logs; see Privacy and action logs.

  • Error messages. A raw exception message can reveal paths, SQL or hostnames. Show a language string to the visitor and log the details; see Debugging.

Installer scripts and packaging

  • The package is part of the attack surface. script.php and the SQL run with the privileges of the installing Super User. Keep installer logic minimal, do not download and execute remote code, and do not leave diagnostic or backup files (.sql, .zip, .bak, .env, .git) in the zip. See Installer scripts and SQL updates.

  • Update servers over HTTPS only, and use the download-key mechanism for paid extensions rather than public URLs. Core updates are TUF-verified since 5.1; extension updates carry a security severity flag from 6.2, which lets the administrator see which updates fix vulnerabilities. See Update servers and the JED.

  • Plugin groups. Never put your own plugins in the behaviour group, which core uses for compatibility plugins and which the 6.x upgrade path switches on and off.

  • Plugin classes get the application and database from the Aware traits (getApplication(), getDatabase()). Joomla 7.0 removes the auto-detection of $app and $db properties.

The JED Checker

The Joomla Extensions Directory (JED) reviews extensions against published standards, and the JED Checker inspects an extension zip for compliance with those standards before you submit it; it is useful even for extensions that are never listed. The JED standards concern the same ground as this page (guards against direct file access, safe handling of input and output, request tokens, and a clean package), so the checklist at the end of this page is a good pre-flight for it. Run it in the CI job that builds the zip, as described in Build and CI for extensions.

Two companion lists are worth knowing: the Vulnerable Extensions List (VEL) kept by the Joomla project records extensions with known flaws, and the Security Centre publishes core advisories. Subscribe to the feed, and treat "compromised" as a case for patching, not a reason for a rewrite.

Lessons from recent core advisories

The Developer Network’s Security Centre publishes every core advisory with the affected and fixed versions. The 2026 advisories between August and September (fixed in 5.4.8/6.1.3 and 5.4.9/6.1.4) fall into a handful of patterns. They are useful here because they are bugs in code written by experienced developers, and the patterns match the checklist above.

Pattern Advisories (titles as published) What the extension author should do

SSRF

"SSRF vectors in various core extensions" (High)

Allow-list outbound hosts and schemes, check resolved addresses, control redirects, set timeouts. See the SSRF section.

ACL gaps in web services

"Improper ACL checks for various webservice edit tasks", "for access level webservice endpoints", "for category webservice endpoints", "for custom fields webservice endpoints", "Inconsistent ACL checks for mutating webservice endpoints"

Call allowEdit()/allowAdd() or authorise() in every API route you add or override; test with a token of a low-privilege user.

ACL gaps in other mutation paths

"Improper ACL checks for workflow stage changes", "for batch copy actions", "Unauthorized user creation via profile.save controller"

Check each custom task, batch operation and shortcut controller on the item and the target. Do not assume that the form restricts what the controller accepts.

ACL gaps in reads

"Improper ACL checks in outputs for tagged items", "in content history comparison view", "when injecting schema.org contact data"

Apply the viewing access level to every query and every joined table that reaches the output.

XSS filter bypasses

"InputFilter XSS bypass via whitespace in HTML data URIs", "XSS filter bypass in InputFilter via HTML5 entity decode mismatch"

Treat filtering as a first layer. Escape on output. Keep Joomla updated: the fix is in core, not in your code.

Missing output escaping

"XSS in module list", "XSS in link toolbar layout", "XSS in generic media output layouts", "XSS in HTMLHelper::link method", "XSS through schema.org outputs", "XSS in HTML mail templates"

escape() or htmlspecialchars() every dynamic value, including titles, links, file names and mail variables.

Unsafe files

"Unrestricted uploads of SHTML files", "Arbitrary directory deletion via cache purge action"

Use allow-lists for file types, build paths from validated names, and keep deletions inside a known folder (Path::clean() plus a prefix check).

Authentication state

"MFA authentication bypass", "MFA bypass via rememberme cookies", "Improper CORS origin validation"

Do not roll your own login shortcuts; use the authentication plugins and, for APIs, validate the Origin against a list.

Three habits stand out. Treat every endpoint as public until proven otherwise; the web-services ACL gaps arose because the same action could be reached through a route the human interface did not use. Test the negative: write a test that logs in as a user without the right and expects a 403 (see Testing extensions). Update promptly: every one of these fixes reached sites only through a core update, which is why automated core updates and a documented patch window are part of the security design of a site, not a convenience.

A review checklist for com_tallpine

Area Check

Entry

Every PHP file starts with \defined('_JEXEC') or die;. No standalone .php URL exists. No JPATH_PLATFORM guard remains.

Input

Each read has a filter or goes through a form. No $_GET, $_POST, $_REQUEST or $_SERVER in extension code. RAW is used only where a comment explains why.

Output

$this->escape() or htmlspecialchars($v, ENT_QUOTES, 'UTF-8') on every dynamic value in templates, modules and mail layouts. No innerHTML with server data. Data to JavaScript goes through addScriptOptions.

CSRF

HTMLHelper::_('form.token') in every form, $this->checkToken() at the top of every task that writes, no state change by GET. AJAX sends X-CSRF-Token.

ACL

authorise() on the right asset in each task, batch action, order action and API route. Reads filter by access level. Tests exercise a low-privilege user.

Database

bind(), whereIn(), quoteName(); whitelisted ordering; no string-built SQL.

Files

isSafeFile(), canUpload() with an allow-list, server-generated names, path prefix check, SVG sanitised or refused.

Network

Allow-listed hosts, public-address check, timeouts, no blind redirects, Joomla\Http\HttpFactory.

Assets

Files in media/, loaded through the Web Asset Manager, no inline script or style, no unvetted CDN.

Secrets

No keys, passwords or tokens in the repository; parameters or environment only.

Package

No stray files in the zip, HTTPS update server, JED Checker clean, tests and static analysis in CI.

Common errors and pitfalls

Symptom or mistake Cause and fix

"The most recent request was denied because it had an invalid security token"

The form lacks HTMLHelper::_('form.token'), the session expired, the page was cached with a stale token, or an AJAX call did not send X-CSRF-Token. Exclude the form from page caching and send the header.

Blank page after upgrading to 6.0 with no error

The file starts with defined('JPATH_PLATFORM') or die; and BC6 is off. Replace the guard with defined('_JEXEC') or die;.

Class "Joomla\CMS\Input\Input" not found

The type hint or use names the removed CMS class. Use \Joomla\Input\Input.

getBool('flag') is always true

BOOL returns true for any non-empty string, including "false". Use getInt('flag', 0) and compare with 1.

Users can edit items they should not

Only the component asset was checked. Check the item asset and, for core.edit.own, the owner.

Search with % or _ returns everything

User wildcards were not escaped: use $db->escape($search, true) before adding your own % and bind the result.

Uploaded .shtml or .php.jpg runs on the server

Block-list instead of allow-list, or the upload folder executes scripts. Use canUpload() and isSafeFile() and configure the folder to serve static files only.

Call to undefined method on Factory::getUser(), Factory::getDbo() or JFactory

Legacy calls removed from the default install. Use getIdentity(), getDatabase() and the container.

A script is blocked by the CSP

It is inline or loaded from an origin not in the policy. Move it into media/ and use the Web Asset Manager.

An API client can change data without a permission

A custom route skips allowEdit(). Add the check and a test.

A return URL sends users to another site

The target was not checked with Uri::isInternal().

Version notes

  • Joomla 6.0 removed JPATH_PLATFORM from the default install (BC6 only), the CMS Input class, and the default Joomla\CMS\Filesystem; deprecated \Joomla\CMS\Http\HttpFactory; and changed AdminModel::getItem() to return a stdClass, so code that calls $item->get() on it fails.

  • Joomla 5.0 introduced concrete event classes for plugins, which replace generic Event::getArgument() arrays.

  • Joomla 5.1 added TUF verification of core updates; 5.4 and 6.0 added Automated Core Updates; 6.1 added the Proof-of-Work captcha plugin; 6.2 added a security-severity flag to extension updates.

  • Joomla 4.2 moved Multi-Factor Authentication into core and replaced the old two-factor authentication.

  • Joomla 4.0 switched escape() to ENT_QUOTES.

  • Joomla 7.0 removes the automatic $app and $db properties of plugins.

Where older books and tutorials are outdated

  • Developing Extensions for Joomla! 5 (Cámara Mora, 2023) has a security checklist and the JED Checker that remain useful. Where it shows a non-namespaced CMSPlugin, a $db plugin property, generic Event::getArgument() arrays, Factory::getLanguage() or get('DatabaseDriver'), the 6.x equivalents are a SubscriberInterface plugin with concrete event classes, the Aware traits and the container. It targets Joomla 5.0.

  • Joomla! 4 Masterclass (Marzo, 2023) targets Joomla 4, which is end of life since October 2025; check any security advice about the administrator against the 6.x defaults.

  • Wiki tutorials from the 2.5 and 3.x era use JRequest::getVar(), JFactory::getDbo(), $db->quote() and JSession::checkToken(). JRequest was removed long ago, the J* names work only through BC6, and the wiki pages mark themselves obsolete. The replacements are Input with a filter, getDatabase() with bind(), and Session::checkToken().