Nitro

Going further

Security

Every #[Server] call carries the component's state, signed with the application key, the session's own secret and the logged-in user. The endpoint refuses anything the page could not have sent.

On this page

What the endpoint refuses

Nitro checks every request to its endpoint. It refuses these requests, with these status codes:

Refused Status
A changed state, or a state made in another session or for another user. This includes another visitor's state, and one from before a logout, even an Auth::logout() that keeps the session 419
A state older than nitro.state.lifetime , when you have set one 419
A method that isn't #[Server] , a locked or unknown property, or a value or argument of the wrong type 403
An upload to anything other than an Upload or #[Uploads] property 403
A visitor who no longer passes the page's authorization middleware ( auth , can , verified , ..., and your own middleware that extends them), or a page whose route no longer exists 403
A partial reload that was rendered for a different page than the component's 403

An upload reference is only valid in the session that uploaded the file. Redirects only go to http(s) URLs and paths, so redirect('javascript:...') throws an exception, and the browser refuses such a redirect too. The endpoint runs in the web middleware group, with sessions and CSRF protection.

What the browser can see

Every public property is part of the page's state, including #[Locked] ones. A model property sends its toArray(): attributes listed in $hidden are left out, but loaded relations are sent. Keep secrets in protected or private properties, or load them in the #[Server] method that needs them.

PHP
class NewsletterPanel extends Component
{
    public int $issueId;              // Sent to the browser.
    #[Locked] public string $status;  // Sent, but can't be changed.

    #[Server]
    public function send(): void
    {
        // Load the subscribers where you need them, not in the state.
        $issue = Issue::findOrFail($this->issueId);

        Mail::to($issue->subscribers)->send(new IssueMail($issue));
    }
}

An unlocked property accepts any value of its type from the browser. Its #[Validate] rules are checked before a #[Server] method runs (see Properties).

Locked doesn't mean current

Signing stops the browser from changing a #[Locked] value. It doesn't stop the same visitor from sending an older state from their own session, as long as that state is still valid. So #[Locked] means a value can't be changed, not that it is up to date. When something must be current, such as a balance, a step that was paid for or a permission, read it from the database in the #[Server] method.

PHP Server
use Nitro\Attributes\Locked;
use Nitro\Attributes\Server;

class Checkout extends Component
{
    #[Locked]
    public int $orderId;

    #[Server]
    public function pay(): void
    {
        // The browser can't change $orderId, so this is the order the page was given.
        // Whether it is still unpaid comes from the database, not from the state.
        $order = auth()->user()->orders()->unpaid()->findOrFail($this->orderId);

        $order->pay();
    }
}

Trusting a child's props

A child component is created with the props its parent's view passes it, taken from the parent's state at that moment. During a #[Server] call, that state includes the browser's changes. This means a child's mount() can receive a value the browser chose, and a #[Locked] property set from it is only as trustworthy as its source. Pass children values the parent keeps #[Locked], or check the value in the child's mount().

Configuring sessions and lifetimes

PHP
// config/nitro.php
'state' => [
    'bind_to_session' => true,   // Turn off only for pages cached as whole HTML for every visitor.
    'lifetime' => null,          // In seconds. Set it when an old state must never come back.
],

When a call is refused with a 419, the page reloads with fresh state. To do something else instead, listen for the nitro:expired event and call preventDefault():

JavaScript
addEventListener('nitro:expired', (event) => {
    event.preventDefault();
    showBanner('This page was open too long. Reload to carry on.');
});

Tested by forging requests

Every refusal above is tested with hand-made requests, in every form a forger might try, and by fuzzing the endpoint with malformed requests. Each one is refused, nothing runs, and nothing answers with a 500 error.

Next steps