diff --git a/administrator/components/com_content/config.xml b/administrator/components/com_content/config.xml
index 8b21fceaf25d6..586c250a0feb3 100644
--- a/administrator/components/com_content/config.xml
+++ b/administrator/components/com_content/config.xml
@@ -330,17 +330,6 @@
<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
deleted file mode 100644
index a5d900d5eaa43..0000000000000
--- a/administrator/components/com_content/src/Service/PreviewTokenService.php
+++ /dev/null
@@ -1,156 +0,0 @@
-<?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 6.2.0
- */
-class PreviewTokenService
-{
- /**
- * The secret key used to sign and verify preview tokens.
- *
- * @var string
- */
- private string $secret;
-
- /**
- * @param string $secret The secret key
- *
- * @since 6.2.0
- */
- 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 6.2.0
- */
- 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 6.2.0
- */
- 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 6.2.0
- */
- 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 6.2.0
- */
- 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 6ded1f8cbe799..cdebf91aacaf9 100644
--- a/administrator/components/com_content/src/View/Article/HtmlView.php
+++ b/administrator/components/com_content/src/View/Article/HtmlView.php
@@ -10,13 +10,11 @@
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
@@ -89,18 +87,6 @@ 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 972328dfdf72e..72b75be7c2f00 100644
--- a/administrator/language/en-GB/com_content.ini
+++ b/administrator/language/en-GB/com_content.ini
@@ -88,8 +88,6 @@ 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 1c91f19f43229..6782dc4cbefdb 100644
--- a/components/com_content/src/Model/ArticleModel.php
+++ b/components/com_content/src/Model/ArticleModel.php
@@ -17,7 +17,6 @@
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;
@@ -69,17 +68,6 @@ 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);
@@ -189,11 +177,8 @@ public function getItem($pk = null)
$query->whereIn($db->quoteName('a.language'), [Factory::getLanguage()->getTag(), '*'], ParameterType::STRING);
}
- $isPreview = $this->getState('article.preview', false);
-
if (
- !$isPreview
- && !$user->authorise('core.edit.state', 'com_content.article.' . $pk)
+ !$user->authorise('core.edit.state', 'com_content.article.' . $pk)
&& !$user->authorise('core.edit', 'com_content.article.' . $pk)
) {
// Filter by start and end dates.
@@ -222,7 +207,7 @@ public function getItem($pk = null)
$published = $this->getState('filter.published');
$archived = $this->getState('filter.archived');
- if (!$isPreview && is_numeric($published)) {
+ if (is_numeric($published)) {
$query->whereIn($db->quoteName('a.state'), [(int) $published, (int) $archived]);
}
@@ -235,7 +220,7 @@ public function getItem($pk = null)
}
// Check for published state if filter set.
- if (!$isPreview && (is_numeric($published) || is_numeric($archived)) && ($data->state != $published && $data->state != $archived)) {
+ if ((is_numeric($published) || is_numeric($archived)) && ($data->state != $published && $data->state != $archived)) {
throw new \Exception(Text::_('COM_CONTENT_ERROR_ARTICLE_NOT_FOUND'), 404);
}
@@ -277,8 +262,8 @@ public function getItem($pk = null)
}
// Compute view access permissions.
- if ($isPreview || $this->getState('filter.access')) {
- // Valid preview token or access filter set — user can view.
+ if ($this->getState('filter.access')) {
+ // If the access filter has been set, we already know this 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#48325 Here are the upstream changes:
Click to expand the diff!