Skip to content
V
For developers
Architecture, conventions, the core and security
Security / Link with a key, no sign-in

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#

  1. A response to an advert is saved; beforeSave() gives it a read_key.
  2. sendEmail() builds the link //:Front:Bazaar:Response:markRead with the parameters slug (the fixed page id 2011, for Comcat 2012), id and key, and puts it into the template as {$markReadLink}.
  3. The receiver clicks. The presenter sets the headers Referrer-Policy: no-referrer, Cache-Control: no-store and X-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() returns null for 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; save therefore 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#

  1. 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).
  2. Entity: a property with #[ExternalReadonly], a getter and a setter.
  3. Generate the key in beforeSave() on INSERT: bin2hex(random_bytes(24)).
  4. Compare only with hash_equals and never ==; reject an empty value.
  5. An Api action without action*() with requireApiPermission(..., ownedData: ['…Key' => $key]) and the check in isReceiver(). A receiver (+ admin) grant on the key; switch guest and registrated off.
  6. A public page in the pages table with logged = 0, without show_in_sitemap. A route without a regular expression on id and key: a bad link shape must end with the same message, not a 404.
  7. The page must set Referrer-Policy, Cache-Control: no-store, X-Robots-Tag.
  8. Handle the robot through CrawlerDetector: a robot writes nothing.
  9. Test from the contract: a valid key ✓, wrong ✗, no key ✗, a different module ✗, deleted ✗, all the invalid cases return the same answer; pattern ResponseMarkReadKeyTest.

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:

  1. sql/2026-10-02_11_response_read_key_column.sql — the column read_key on both response tables (before deploying the code),
  2. …_08_response_mark_read_pages.sql — the public pages 2011/2012 and routes,
  3. …_09_response_mark_read_privilege.sql — the RBAC keys markReadByKey,
  4. …_12_response_receiver_role.sql — the role receiver and its grants (replaces the grant from _09),
  5. …_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:*).

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=…
E-mail 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

Cookie settings