A link with a key, no sign-in#
A pattern for an action opened by a signed-out person from a link in an email, which may
do exactly one thing. Today it is used by the Označit jako přečtené (mark as read) button
in the email about a response to an advert (markReadByKey). You can build a confirmation, an
unsubscribe or a one-off action the same way. It uses the
conditional role receiver.
What it consists of#
| Part | Where | Job |
|---|---|---|
| The key | the column read_key (VARCHAR 64) on the response; Response::$readKey |
a random secret string, 24 random bytes = 48 hex characters |
| Generation | ResponseMarkReadTrait::generateReadKey() in the Api manager's beforeSave() |
the key is created when the record is created |
| Comparison | ResponseMarkReadTrait::readKeyMatches() |
hash_equals; an empty stored key and an empty given key never pass |
| The link | ResponseManager::buildMarkReadLink() |
an absolute address of a public page; goes into the email as markReadLink |
| Public page | Front:Bazaar:Response:markRead, Front:Comcat:Response:markRead |
calls the Api, shows a message |
| Api action | internalActionMarkReadByKey() in ResponseReceiverTrait |
internal, without action*() |
| RBAC | the key :Api:{Bazaar,Comcat}:Response:markReadByKey |
grant only for receiver (+ admin), guest and registrated have 0 |
| The write | markRead($id) |
switches read = 1, nothing else |
How it flows#
- A response to an advert is saved;
beforeSave()gives it aread_key. sendEmail()builds the link//:Front:Bazaar:Response:markReadwith the parametersslug(the fixed page id 2011, for Comcat 2012),idandkey, and puts it into the template as{$markReadLink}.- The receiver clicks. The presenter sets the headers
Referrer-Policy: no-referrer,Cache-Control: no-storeandX-Robots-Tag: noindex, nofollow(the address carries a secret).
!!! warning "The .htaccess Referrer-Policy overrides the page's value"
The response therefore carries strict-origin-when-cross-origin, not no-referrer:
the Header always set rule in www/.htaccess replaces it. A foreign site receives only
the site's address, not the path with the key, so the key does not leak — see
Response headers.
4. If CrawlerDetector recognises a robot, it writes nothing and returns the state
crawler.
5. id must be a small positive number and the key non-empty, otherwise the state invalid.
6. The front does not verify the key — it sends id and key to the Api
(ResponseManager::markReadByKey() → the bridge mark-read-by-key).
7. Api: requireApiPermission('markReadByKey', owned: …, ownedData: ['readKey' => $key]). The
gate lets the receiver grant through and calls isReceiver(), which compares the key
(isResponseReceiver($response, $readKey), branch 4). Only then markRead($id).
8. The page shows read, invalid or crawler.
The contract#
The action does one thing and reveals nothing
It switches read = 1. It returns nothing from the response or the advert — only the
Result envelope. The page shows only a message. An invalid key, a response without a key,
a deleted and a non-existent response return the same result (INVALID_KEY) and
HTTP 200, so the action is not an oracle on a record's existence.
- The key is not deleted after use. A valid key on an already read response is a success again, so the link can be opened repeatedly.
- Modules are separated by the table. Each manager looks in its own table of responses; a key from Bazaar does not pass for a response with the same id in Comcat.
- Older records have no key and none is back-filled.
buildMarkReadLink()returnsnullfor them and the template gets by without the button through the block{if !empty($markReadLink)}. - The key must not sit in a column writable from outside.
#[ExternalReadonly]does not accept it from outside;savetherefore does not overwrite it. The key is never stored in the session or the JWT. - From outside the key does not help. The key branch exists only on the internal path; the
external
verifyReceiver()does not receive the key.
Procedure: the same pattern for a new action#
- Add a column with the key to the record (
VARCHAR(64) NULL, no back-fill, no unique index if lookup is by primary key). Idempotent SQL (ADD COLUMN IF NOT EXISTS), before deploying the code (the entity reads the column). - Entity: a property with
#[ExternalReadonly], a getter and a setter. - Generate the key in
beforeSave()on INSERT:bin2hex(random_bytes(24)). - Compare only with
hash_equalsand never==; reject an empty value. - An Api action without
action*()withrequireApiPermission(..., ownedData: ['…Key' => $key])and the check inisReceiver(). Areceiver(+admin) grant on the key; switchguestandregistratedoff. - A public page in the pages table with
logged = 0, withoutshow_in_sitemap. A route without a regular expression onidandkey: a bad link shape must end with the same message, not a 404. - The page must set
Referrer-Policy,Cache-Control: no-store,X-Robots-Tag. - Handle the robot through
CrawlerDetector: a robot writes nothing. - Test from the contract: a valid key ✓, wrong ✗, no key ✗, a different module ✗, deleted ✗,
all the invalid cases return the same answer; pattern
ResponseMarkReadKeyTest.
Trap: scanners open links#
Mail clients and security scanners sometimes open the links in an email themselves, with no robot User-Agent. The record is then marked without a person doing anything. It is a consciously accepted consequence of the decision "GET without a confirmation button"; the damage is just a switched flag. For an action with consequences add a confirmation step (POST).
Writing on GET elsewhere in the front rests on isSameSite()
The reply detail on the site sets read only for a request from the site's own origin.
A link opened from webmail shows the response but does not mark it — that is why the
button with a key exists. See
CORS, rate limit and audit.
Migrations#
The order in which the SQL for this feature is applied on deployment:
sql/2026-10-02_11_response_read_key_column.sql— the columnread_keyon both response tables (before deploying the code),…_08_response_mark_read_pages.sql— the public pages 2011/2012 and routes,…_09_response_mark_read_privilege.sql— the RBAC keysmarkReadByKey,…_12_response_receiver_role.sql— the rolereceiverand its grants (replaces the grant from_09),…_10_response_mark_read_email_button.sql— the button block in the email templates.
The rollbacks have the prefixes _98_, _99_, _88_, _89_, _90_. After applying clear
temp/cache and the route-table cache in Redis (app:cache:route:*).
Variant: the advert management link#
The same principle (the secret only in the email, a hash in the DB) is used by the Open
advert management link in the insertion email of an advert posted without an account
(a Bazaar advert, a Comcat company profile and a Discussion
post). The email used to carry the readable management password; it no longer carries it at
all. The record implements ManageKeyAwareInterface (the password, or for a guest post a random
secret, plus the key hash and expiry), and issuing and verifying is done by the shared
ManageKeyTrait (app/Core/Traits/Api/Models/Managers/ManageKeyTrait.php).
| Part | Where | Job |
|---|---|---|
| Key rules | App\Core\Security\AdvertManageKey |
pure functions: 32 random bytes as hex, the hash sha256(key \| advert password hash), validity TTL_SECONDS = 7 days |
| Storage | columns manage_key_hash, manage_key_expires on the advert (FieldAccess is None everywhere) |
the readable key exists only in the email |
| Issuing | ManageKeyTrait::issueManageKey() after the insert commit; called by AdvertEmailTrait only for the BZ_ADVERT_INSERT email (Comcat CC_ADVERT_INSERT), in Discussion by MessageConfirmationTrait |
the key goes into the edit link as the manageKey parameter |
| Redeeming | front edit?id=…&manageKey=…, redeemManageKeyFromLink() → internal Api action redeemManageKey (ManageKeyTrait::redeemManageKey()) |
on a match it opens the edit session just like entering the password, with a flash message and a redirect without the key |
The key can be used repeatedly during its validity (a mail link scanner does not burn
it before the owner clicks). It is bound to the advert password hash, so as soon as the
advert password changes (reset, edit, an administrator), the link stops working without
anything being deleted. After expiry only the password chosen at insertion or "Forgotten
password" works. The password in the outgoing mail queue is masked, and whoever proved
access with the advert password sees unmasked personal data only in the rows of that
advert (PersonalDataPolicy::apply($payload, $capabilityRows)).
Variant: confirmation of a record by its author#
An advert, a company profile and a discussion post for which the module requires confirmation
by the author (see Classifieds)
get a key on insert, and the author receives an e-mail with the link ?do=confirm&key=…. A
guest author clicks the link too, so security rests on the key, not on a login.
| Part | Where | Job |
|---|---|---|
| Key rules | App\Core\Security\AdvertConfirmationKey |
32 random bytes (64 hex), only sha256 in the database; no expiry |
| Storage | the activation_key column |
pending (hash(key)) → consumed (hash('consumed:' . hash(key))) |
| Issuing | InitialConfirmationTrait::applyInitialConfirmation() in beforeSave() on insert |
the plain key travels only through $meta to the e-mail after the commit |
| Redeeming | ConfirmByKeyTrait::confirmByKey($id, $key, $ip); advert modules through AdvertConfirmByKeyTrait |
internal Api action confirmByKey without action*(), the RBAC key has guest = 1 |
| Limit | ratelimit.advertConfirm (config/Shared/ratelimit.neon) |
failures per record and per IP, shared by all modules, keys separated by module |
| Input | Answer |
|---|---|
| unknown, deleted or banned record; empty or wrong key; exhausted limit | one error (ADVERT_CONFIRMATION_KEY_INVALID, for discussion MESSAGE_CONFIRMATION_KEY_INVALID) |
| correct key of an unconfirmed record | confirmed_at = now, the key is consumed, follow-ups (afterRecordConfirmed(): index, category counts) |
| correct key of an already confirmed record | success without change (a repeated click from the mailbox is not an error) |
Unlike the read link, the key is consumed: a consumed digest confirms nothing anymore, not
even when an administrator clears confirmed_at later. A correct key does not spend the quota,
but once it is exhausted even a correct key is rejected until the window expires. No e-mail is
sent after the confirmation.
Read the record fresh from the database
findRecordForConfirmation() must bypass the query cache. From a stale copy a consumed key
could be used once more.
Do not write the confirmation with updateColumn() from the front end
The Api updateColumns rejects a guest (PERMISSION_DENIED), so a guest who posted an
advert without an account would never be confirmed. The write is done by confirmByKey()
through the internal action.
The front-end confirmation handler needs #[CrossOrigin]
Nette forces a same-origin check on every handle*(). A click from a mail client is
cross-site, so without #[CrossOrigin] it ends as detected CSRF: a redirect without do
and key, and the handler never runs. handleConfirm() in Bazaar, Comcat and Discussion
therefore carries the attribute and reads only the id from the URL, because an unconfirmed
record is hidden from a guest and actionDetail() does not load it.
Variant: forgotten advert password#
An advert posted without an account (a Bazaar advert, a Comcat company profile) has its own "forgot password". The e-mail carries no new password but a one-time link to the edit page, where the owner chooses one. The current password stays valid until a new one is saved.
| Part | Where | Job |
|---|---|---|
| Request | modal ForgotPasswordForm → :Api:{Bazaar,Comcat}:Advert:sendPasswordReset (advertId, email) |
the e-mail is compared with the advert e-mail (case-insensitive); on a match the link is sent |
| Key | PasswordResetToken: 32 random bytes, only the sha256 stored in password_reset_hash, valid 1 h in password_reset_expires |
the readable key goes only into the link edit?id=…&resetKey=… |
BZ_ADVERT_NEW_PASSWORD, CC_ADVERT_NEW_PASSWORD (variables advertName, advertDetailLink, link) |
||
| New password | actionEdit() switches to the passwordReset view when the visitor may not edit and the URL carries resetKey; submit → :Api:…:Advert:resetPassword |
checks the key, writes the password hash (at least PasswordPolicy::ADVERT_MIN_LENGTH = 6 characters) and clears the key |
| Logic | AdvertPasswordResetTrait, one implementation for both modules |
the module supplies the e-mail, the presenter and the detail and edit page ids |
After a successful save the visitor gets the same edit session as after typing the password
([id, hash, expiration], the hash read again from the Api), and the edit page opens without
resetKey in the URL.
sendPasswordReset input |
Answer |
|---|---|
| unknown advert, non-matching e-mail, exhausted limit, failed delivery | always OK |
| match | OK, the link is sent |
resetPassword input |
Answer |
|---|---|
| unknown advert; wrong, expired or used key; exhausted limit | USER_PASSWORD_RESET_KEY_INVALID |
| short password | INVALID_LENGTH, the key is not consumed |
| correct key and password | OK, the key is cleared |
The Api holds the limits with the ratelimit.sendNewPassword values: the request per advert
(3 per hour) and per IP (10 per hour), the password save per IP. The limit keys are separated
by module.
The request answer must not reveal whether the e-mail matches
The advert e-mail is often visible on the detail page. If the action answered a
non-matching e-mail differently, it could be used to check guesses. So the answer is always
OK and failures go only to the email and auth logs.
A failed delivery restores the previous key
When the e-mail is not sent, the key columns go back to their values before the request, but only while the row still carries the digest just issued. A concurrent successful request thus wins, and a link sent earlier stays valid.
A limit in the session is no limit
The attempt limit belongs on the server. Anyone who drops the cookie bypasses a session limit.
Where to look#
| I want | Where |
|---|---|
| the key logic | app/Core/Traits/Api/Models/Managers/ResponseMarkReadTrait.php |
| the receiver decision | app/Core/Traits/Api/Presenters/ResponseReceiverTrait.php |
| the public page | app/UI/Front/{Bazaar,Comcat}/Presenters/Traits/Details/ResponseMarkReadTrait.php |
| the page template | app/UI/Front/{Bazaar,Comcat}/Templates/Response/markRead.latte |
| confirming a record with a key | app/Core/Traits/Api/Models/Managers/ConfirmByKeyTrait.php, app/Core/Security/AdvertConfirmationKey.php |
| the forgotten advert password | app/Core/Traits/Api/Models/Managers/AdvertPasswordResetTrait.php, app/Core/Security/PasswordResetToken.php, front app/UI/Front/{Bazaar,Comcat}/Presenters/Traits/Forms/AdvertPasswordResetFormTrait.php |