Skip to content

Locked Signals

Locked signals are server-protected reactive variables that prevent client-side tampering. They allow you to safely include sensitive data like user IDs, permissions, or pricing information in your frontend while guaranteeing the values cannot be modified by malicious users.

Security Feature

Locked signals are a Hyper-specific security enhancement. They're validated on every request and throw exceptions if tampering is detected, making them essential for protecting sensitive application data.

The Problem

In traditional reactive frameworks, all state lives in the browser and can be manipulated using DevTools:

blade
<div data-signals="{userId: 123, role: 'admin', price: 99.99}">
    <button data-on:click="@postx('/purchase')">Buy Now</button>
</div>

A malicious user could open DevTools and change these values:

javascript
// In browser console:
$userId = 999  // Pretend to be another user
$role = 'admin' // Escalate privileges
$price = 0.01  // Change the price

When they click "Buy Now", your server receives the tampered values, potentially causing security vulnerabilities or financial loss.

The Solution

Locked signals solve this by storing values securely on the server and validating them on every request:

blade
<div @signals(['userId_' => auth()->id(), 'role_' => auth()->user()->role, 'price_' => 99.99])>
    <button data-on:click="@postx('/purchase')">Buy Now</button>
</div>

Now if a user tries to tamper with these values, Hyper throws a HyperSignalTamperedException and rejects the request before your controller code runs.

How Locked Signals Work

Creating Locked Signals

Locked signals must be created using the @signals directive. The underscore suffix (_) marks them as locked.

Method 1: Literal Variable Names (Recommended)

Create PHP variables with trailing underscores - the variable name becomes the signal name:

blade
@php
    // Variable names MUST include the trailing underscore
    $userId_ = auth()->id();
    $role_ = auth()->user()->role;
    $maxDiscount_ = 0.20;
@endphp

<div @signals($userId_, $role_, $maxDiscount_)>
    <!-- Creates locked signals: userId_, role_, maxDiscount_ -->
</div>

Literal Variable Naming

The directive uses variable names exactly as written. To create a userId_ signal, your PHP variable must be named $userId_ (not $userId).

Method 2: Array Syntax

Manually specify signal names with underscore suffixes:

blade
@php
    // Regular PHP variables without underscores
    $userId = auth()->id();
    $role = auth()->user()->role;
@endphp

<div @signals([
    'userId_' => $userId,      // Signal name has _, variable doesn't
    'role_' => $role,
    'maxDiscount_' => 0.20
])>
    <!-- Locked signals created from array -->
</div>

Method 3: Spread Syntax

Spread arrays to create multiple locked signals:

blade
@php
    // Array variable with data to spread
    $permissions_ = [
        'canEdit' => $user->can('edit'),
        'canDelete' => $user->can('delete')
    ];
@endphp

<div @signals(...$permissions_)>
    <!-- Creates signals: canEdit, canDelete (NOT locked unless explicitly named with _) -->
</div>

Spread Behavior

When you spread $permissions_, the keys from the array become signal names. To make them locked, the array keys (not the variable name) must end with _:

blade
$permissions_ = [
    'canEdit_' => $user->can('edit'),    // Locked signal
    'canDelete_' => $user->can('delete')  // Locked signal
];

Critical Requirement

Locked signals MUST be created with @signals, not data-signals. Using data-signals won't provide any security:

blade
<!-- ❌ This doesn't create protected locked signals -->
<div data-signals="{userId_: 123}">

<!-- ✅ This does -->
<div @signals(['userId_' => 123])>

The security mechanism requires PHP-side processing to store and validate values.

The Protection Mechanism

When you create locked signals with @signals, Hyper performs these steps:

1. Initial Request (Page Load):

blade
<div @signals(['userId_' => auth()->id()])>

Hyper:

  • Encrypts the value using Laravel's Crypt facade
  • Stores the encrypted value in the session
  • Adds a cryptographic signature (MAC) for tampering detection
  • Sends the signal to the frontend

2. Subsequent Requests (Hyper Actions):

blade
<button data-on:click="@postx('/purchase')">Buy</button>

When the user clicks, Hyper:

  • Receives all signals (including userId_) from the frontend
  • Retrieves the encrypted value from the session
  • Compares the received value with the stored value
  • Throws HyperSignalTamperedException if they don't match
  • Allows the request to proceed only if validation passes

3. If Tampering is Detected:

php
// This code never runs if userId_ was tampered with
public function purchase()
{
    $userId = signals('userId_');
    // ...
}

The exception is thrown before your controller method executes, preventing any malicious code from reaching your business logic.

Complete Lifecycle Example

Let's walk through a real-world example of locked signals protecting a shopping cart:

Blade View:

blade
@php
    $userId_ = auth()->id();
    $originalPrice_ = $product->price;
@endphp

<div @signals($userId_, ['cartTotal' => 0], $originalPrice_)>
    <h2>{{ $product->name }}</h2>
    <p>Price: $<span data-text="$originalPrice_"></span></p>

    <input type="number" data-bind="quantity" value="1" min="1" />

    <div data-computed:cartTotal="$quantity * $originalPrice_"></div>
    <p>Total: $<span data-text="$cartTotal"></span></p>

    <button data-on:click="@postx('/cart/add')">Add to Cart</button>
</div>

Controller:

php
public function addToCart()
{
    // These values are automatically validated before this method runs
    $userId = signals('userId_');        // Protected - can't be changed
    $originalPrice = signals('originalPrice_');  // Protected - can't be changed
    $quantity = signals('quantity');     // Regular signal - user can modify

    // Safe to use these values - tampering would have thrown exception
    CartItem::create([
        'user_id' => $userId,
        'quantity' => $quantity,
        'price' => $originalPrice * $quantity,  // Use protected price
    ]);

    return hyper()->signals([
        'message' => 'Added to cart!',
        'quantity' => 1,  // Reset quantity
    ]);
}

What This Protects:

  • ✅ User cannot change userId_ to add items to someone else's cart
  • ✅ User cannot change originalPrice_ to get a discount
  • ⚠️ User can change quantity (it's a regular signal)

Mixing Signal Types

You can use all three signal types together:

blade
@php
    $userId_ = auth()->id();           // Locked signal variable
    $username = auth()->user()->name;  // Regular signal variable
    $_cartOpen = false;                // Local signal variable
@endphp

<div @signals($userId_, $username, $_cartOpen)>
    <!-- userId_ protected, username sent but not protected, _cartOpen never sent -->
</div>

Or with array syntax:

blade
<div @signals([
    'userId_' => auth()->id(),      // Locked - protected
    'username' => auth()->user()->name,  // Regular - sent to server
    '_cartOpen' => false            // Local - stays in browser
])>
</div>
Signal TypeSyntaxSent to ServerProtected from Tampering
Regularusername✅ Yes❌ No
Local_cartOpen❌ NoN/A (never sent)
LockeduserId_✅ Yes✅ Yes

Reading Locked Signals

In your controllers, read locked signals the same way as regular signals:

php
public function process()
{
    // No special syntax needed - validation is automatic
    $userId = signals('userId_');
    $role = signals('role_');

    // Or get all signals including locked
    $all = signals()->all();

    // Check if a locked signal exists
    if (signals()->has('maxDiscount_')) {
        $discount = signals('maxDiscount_');
    }
}

The validation happens automatically when you call signals()->all() or signals()->get(). If tampering is detected, an exception is thrown before your code executes.

Updating Locked Signals

You can update locked signals from the server, and the new values will be protected:

php
public function upgradeRole()
{
    $user = auth()->user();
    $user->role = 'admin';
    $user->save();

    // Update the locked signal with new value
    return hyper()->signals([
        'role_' => 'admin'  // New value is automatically encrypted and stored
    ]);
}

When you update a locked signal via hyper()->signals(), Hyper:

  1. Stores the new encrypted value in the session
  2. Sends the new value to the frontend
  3. Protects the new value on subsequent requests

Deleting Locked Signals

Use hyper()->forget() to delete locked signals:

php
public function logout()
{
    auth()->logout();

    // Remove locked signals
    return hyper()->forget(['userId_', 'role_', 'permissions_']);
}

When you forget a locked signal:

  1. It's removed from the frontend (set to null)
  2. It's removed from the session storage
  3. Future requests won't validate it

Using null to delete:

php
// These are equivalent
return hyper()->forget('userId_');
return hyper()->signals(['userId_' => null]);

Setting a locked signal to null has the same effect as using forget() - it clears both the frontend value and the session storage.

Security Considerations

What Locked Signals Protect Against

Client-side value tampering

javascript
// User tries: $userId_ = 999
// Result: HyperSignalTamperedException thrown

DevTools manipulation

javascript
// User tries: $$signals.userId_ = 999
// Result: Tampering detected on next request

Request payload modification

bash
# User modifies POST body: {"userId_": 999}
# Result: Exception thrown

What Locked Signals Don't Protect Against

Viewing signal values

Locked signals are sent to the frontend as plain text. Users can see the values in DevTools - they just can't change them.

blade
<!-- User can see userId_ = 123 in browser, but can't change it -->
<div @signals(['userId_' => 123])>

If you need to hide values entirely, don't send them to the frontend.

Session hijacking

If an attacker hijacks a user's session, they inherit that user's locked signals. Locked signals rely on session security.

CSRF attacks

Locked signals don't replace CSRF protection. Always use Hyper's CSRF-protected actions (@postx, @putx, etc.).

Session Storage Considerations

Memory usage:

Each locked signal is stored encrypted in the session. For applications with many concurrent users, consider:

php
// ✅ Good - Only essential protected data
@signals(['userId_' => auth()->id(), 'role_' => $user->role])

// ❌ Avoid - Large data in locked signals
@signals(['allPermissions_' => $user->permissions->toArray()])  // Could be large

Session lifetime:

Locked signals persist as long as the session exists. When the session expires:

  • Locked signals are cleared
  • Next request will be treated as a "first call"
  • New locked signals can be created

Troubleshooting

Exception: "Locked signal was tampered with"

Cause: The signal value received from the frontend doesn't match the stored encrypted value.

Common reasons:

  1. User modified the value in DevTools
  2. Session was cleared but frontend still has the signal
  3. Multiple tabs with different session states

Solution:

php
try {
    $userId = signals('userId_');
} catch (HyperSignalTamperedException $e) {
    // Log the attempt
    Log::warning('Tampering detected', [
        'user_id' => auth()->id(),
        'ip' => request()->ip()
    ]);

    // Clear and redirect
    return hyper()
        ->forget(['userId_', 'role_'])
        ->navigate('/login');
}

Locked Signals Not Working

Problem: Creating locked signals with data-signals instead of @signals

blade
<!-- ❌ This won't work -->
<div data-signals="{userId_: 123}">

<!-- ✅ Must use @signals -->
<div @signals(['userId_' => 123])>

Session Expires But Signals Remain

Problem: User's session expires but locked signals are still in the frontend.

Solution: Check for tampering exceptions and handle gracefully:

php
public function handle($request, Closure $next)
{
    try {
        return $next($request);
    } catch (HyperSignalTamperedException $e) {
        if (!auth()->check()) {
            // Session expired - redirect to login
            return redirect('/login');
        }

        throw $e;  // Re-throw if authenticated
    }
}

Comparison with Alternatives

vs Regular Signals

FeatureRegular SignalsLocked Signals
Client-side tampering❌ Vulnerable✅ Protected
Session storage❌ No✅ Yes (encrypted)
Memory overheadLowHigher
Creation syntaxAny methodMust use @signals
Use caseForm data, UI stateSensitive data

vs Local Signals

FeatureLocal Signals (_prefix)Locked Signals (suffix_)
Sent to server❌ No✅ Yes
ProtectedN/A✅ Yes
Session storage❌ No✅ Yes
Use caseUI stateProtected server data

Performance Implications

Locked signals add minimal overhead:

Per Request:

  • Decrypt session data (~0.1ms)
  • Compare values (~0.01ms)
  • Total: <1ms per request

Session Storage:

  • ~100 bytes per locked signal (encrypted)
  • For 1000 concurrent users with 5 locked signals each: ~500KB

The security benefits far outweigh the minimal performance cost for protecting sensitive data.

Learn More