diff --git a/administrator/components/com_content/config.xml b/administrator/components/com_content/config.xml
index 586c250a0feb3..8b21fceaf25d6 100644
--- a/administrator/components/com_content/config.xml
+++ b/administrator/components/com_content/config.xml
@@ -330,6 +330,17 @@
<option value="1">COM_CONTENT_FIELD_OPTION_BELOW</option>
<option value="-1">JHIDE</option>
</field>
+
+ <field
+ name="preview_token_expiration"
+ type="number"
+ label="COM_CONTENT_FIELD_PREVIEW_TOKEN_EXPIRATION_LABEL"
+ description="COM_CONTENT_FIELD_PREVIEW_TOKEN_EXPIRATION_DESC"
+ default="15"
+ min="1"
+ step="5"
+ filter="integer"
+ />
</fieldset>
<fieldset
name="editinglayout"
diff --git a/administrator/components/com_content/src/Service/PreviewTokenService.php b/administrator/components/com_content/src/Service/PreviewTokenService.php
new file mode 100644
index 0000000000000..e890bbaa498dd
--- /dev/null
+++ b/administrator/components/com_content/src/Service/PreviewTokenService.php
@@ -0,0 +1,156 @@
+<?php
+
+/**
+ * @package Joomla.Administrator
+ * @subpackage com_content
+ *
+ * @copyright (C) 2026 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Content\Administrator\Service;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Service class for generating and validating article preview tokens.
+ *
+ * Tokens are URL-safe, HMAC-SHA256 signed, and time-limited.
+ * They allow unauthenticated public access to article previews without
+ * requiring a frontend login or published state.
+ *
+ * Usage:
+ * $helper = new PreviewTokenService($app->get('secret'));
+ * $token = $helper->createToken($articleId, 24);
+ * $valid = $helper->validateToken($token, $articleId);
+ *
+ * @since __DEPLOY_VERSION__
+ */
+class PreviewTokenService
+{
+ /**
+ * The secret key used to sign and verify preview tokens.
+ *
+ * @var string
+ */
+ private string $secret;
+
+ /**
+ * @param string $secret The secret key
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function __construct(string $secret)
+ {
+ $this->secret = $secret;
+ }
+
+ /**
+ * Generate a signed preview token for a given article.
+ *
+ * @param int $id The article ID.
+ * @param int $expiresInMinutes Number of minutes before the token expires.
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function createToken(int $id, int $expiresInMinutes): string
+ {
+ $payload = $this->encode(json_encode([
+ 'id' => $id,
+ 'exp' => time() + ($expiresInMinutes * 60),
+ ]));
+
+ $signature = $this->encode(hash_hmac('sha256', $payload, $this->secret, true));
+
+ return $payload . '.' . $signature;
+ }
+
+ /**
+ * Validate a preview token against a given article ID.
+ *
+ * Checks that the token:
+ * - Has a valid structure
+ * - Has not been tampered with (HMAC signature check)
+ * - Has not expired
+ * - Belongs to the expected article
+ *
+ * @param string $token The preview token from the URL.
+ * @param int $id The expected article ID.
+ *
+ * @return bool
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ public function validateToken(string $token, int $id): bool
+ {
+ $parts = explode('.', $token);
+
+ if (\count($parts) !== 2) {
+ return false;
+ }
+
+ [$payload, $signature] = $parts;
+
+ if (!hash_equals($this->encode(hash_hmac('sha256', $payload, $this->secret, true)), $signature)) {
+ return false;
+ }
+
+ $decoded = $this->decode($payload);
+
+ if ($decoded === false) {
+ return false;
+ }
+
+ $data = json_decode($decoded, true);
+
+ if (!\is_array($data) || !isset($data['id'], $data['exp'])) {
+ return false;
+ }
+
+ if (!\is_int($data['id']) || !\is_int($data['exp'])) {
+ return false;
+ }
+
+ if ($data['id'] !== $id) {
+ return false;
+ }
+
+ return time() < $data['exp'];
+ }
+
+ /**
+ * Encode binary data to URL-safe Base64 string.
+ *
+ * @param string $data
+ *
+ * @return string
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function encode(string $data): string
+ {
+ return str_replace('=', '', strtr(base64_encode($data), '+/', '-_'));
+ }
+
+ /**
+ * Decode a URL-safe Base64 string to binary data.
+ *
+ * @param string $data
+ *
+ * @return string|false
+ *
+ * @since __DEPLOY_VERSION__
+ */
+ private function decode(string $data): string|false
+ {
+ if ($remainder = \strlen($data) % 4) {
+ $data .= str_repeat('=', 4 - $remainder);
+ }
+
+ return base64_decode(strtr($data, '-_', '+/'));
+ }
+}
diff --git a/administrator/components/com_content/src/View/Article/HtmlView.php b/administrator/components/com_content/src/View/Article/HtmlView.php
index cdebf91aacaf9..6ded1f8cbe799 100644
--- a/administrator/components/com_content/src/View/Article/HtmlView.php
+++ b/administrator/components/com_content/src/View/Article/HtmlView.php
@@ -10,11 +10,13 @@
namespace Joomla\Component\Content\Administrator\View\Article;
+use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use Joomla\CMS\Helper\ContentHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\FormView;
use Joomla\CMS\Toolbar\ToolbarHelper;
+use Joomla\Component\Content\Administrator\Service\PreviewTokenService;
use Joomla\Component\Content\Site\Helper\RouteHelper;
// phpcs:disable PSR1.Files.SideEffects
@@ -87,6 +89,18 @@ protected function initializeView()
$url = RouteHelper::getArticleRoute($this->item->id . ':' . $this->item->alias, $this->item->catid, $this->item->language);
+ // Generate preview token if editing an existing article
+ if ($this->item->id > 0) {
+ $params = ComponentHelper::getParams('com_content');
+ $expiration = (int) $params->get('preview_token_expiration', 15);
+
+ $tokenHelper = new PreviewTokenService(Factory::getApplication()->get('secret'));
+ $token = $tokenHelper->createToken((int) $this->item->id, $expiration);
+
+ // Append token using proper URL query parameter handling
+ $url .= (!str_contains($url, '?') ? '?' : '&') . 'preview_token=' . $token;
+ }
+
$this->previewLink = $url;
$this->jooa11yLink = $url . '&jooa11y=1';
diff --git a/administrator/language/en-GB/com_content.ini b/administrator/language/en-GB/com_content.ini
index 72b75be7c2f00..972328dfdf72e 100644
--- a/administrator/language/en-GB/com_content.ini
+++ b/administrator/language/en-GB/com_content.ini
@@ -88,6 +88,8 @@ COM_CONTENT_FIELD_NOTE_LABEL="Note"
COM_CONTENT_FIELD_OPTION_ABOVE="Above"
COM_CONTENT_FIELD_OPTION_BELOW="Below"
COM_CONTENT_FIELD_OPTION_SPLIT="Split"
+COM_CONTENT_FIELD_PREVIEW_TOKEN_EXPIRATION_DESC="Number of minutes before a preview token expires. Preview tokens allow unauthenticated users to view unpublished articles. Default is 15 minutes."
+COM_CONTENT_FIELD_PREVIEW_TOKEN_EXPIRATION_LABEL="Preview Token Expiration (minutes)"
COM_CONTENT_FIELD_PUBLISH_DOWN_LABEL="Finish Publishing"
COM_CONTENT_FIELD_PUBLISH_UP_LABEL="Start Publishing"
COM_CONTENT_FIELD_SELECT_ARTICLE_LABEL="Select Article"
diff --git a/components/com_content/src/Model/ArticleModel.php b/components/com_content/src/Model/ArticleModel.php
index 6782dc4cbefdb..1c91f19f43229 100644
--- a/components/com_content/src/Model/ArticleModel.php
+++ b/components/com_content/src/Model/ArticleModel.php
@@ -17,6 +17,7 @@
use Joomla\CMS\MVC\Model\ItemModel;
use Joomla\CMS\Table\Content;
use Joomla\Component\Content\Administrator\Extension\ContentComponent;
+use Joomla\Component\Content\Administrator\Service\PreviewTokenService;
use Joomla\Database\ParameterType;
use Joomla\Registry\Registry;
use Joomla\Utilities\IpHelper;
@@ -68,6 +69,17 @@ protected function populateState()
// If $pk is set then authorise on complete asset, else on component only
$asset = empty($pk) ? 'com_content' : 'com_content.article.' . $pk;
+ // Validate preview token if present, before any permission checks.
+ $token = $app->getInput()->getString('preview_token', '');
+
+ if ($token !== '' && $pk > 0) {
+ $previewTokenHelper = new PreviewTokenService($app->get('secret'));
+
+ if ($previewTokenHelper->validateToken($token, $pk)) {
+ $this->setState('article.preview', true);
+ }
+ }
+
if ((!$user->authorise('core.edit.state', $asset)) && (!$user->authorise('core.edit', $asset))) {
$this->setState('filter.published', ContentComponent::CONDITION_PUBLISHED);
$this->setState('filter.archived', ContentComponent::CONDITION_ARCHIVED);
@@ -177,8 +189,11 @@ public function getItem($pk = null)
$query->whereIn($db->quoteName('a.language'), [Factory::getLanguage()->getTag(), '*'], ParameterType::STRING);
}
+ $isPreview = $this->getState('article.preview', false);
+
if (
- !$user->authorise('core.edit.state', 'com_content.article.' . $pk)
+ !$isPreview
+ && !$user->authorise('core.edit.state', 'com_content.article.' . $pk)
&& !$user->authorise('core.edit', 'com_content.article.' . $pk)
) {
// Filter by start and end dates.
@@ -207,7 +222,7 @@ public function getItem($pk = null)
$published = $this->getState('filter.published');
$archived = $this->getState('filter.archived');
- if (is_numeric($published)) {
+ if (!$isPreview && is_numeric($published)) {
$query->whereIn($db->quoteName('a.state'), [(int) $published, (int) $archived]);
}
@@ -220,7 +235,7 @@ public function getItem($pk = null)
}
// Check for published state if filter set.
- if ((is_numeric($published) || is_numeric($archived)) && ($data->state != $published && $data->state != $archived)) {
+ if (!$isPreview && (is_numeric($published) || is_numeric($archived)) && ($data->state != $published && $data->state != $archived)) {
throw new \Exception(Text::_('COM_CONTENT_ERROR_ARTICLE_NOT_FOUND'), 404);
}
@@ -262,8 +277,8 @@ public function getItem($pk = null)
}
// Compute view access permissions.
- if ($this->getState('filter.access')) {
- // If the access filter has been set, we already know this user can view.
+ if ($isPreview || $this->getState('filter.access')) {
+ // Valid preview token or access filter set — user can view.
$data->params->set('access-view', true);
} else {
// If no access filter is set, the layout takes some responsibility for display of limited information.
New language relevant PR in upstream repo: joomla/joomla-cms#48030 Here are the upstream changes:
Click to expand the diff!