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:
<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:
// In browser console:
$userId = 999 // Pretend to be another user
$role = 'admin' // Escalate privileges
$price = 0.01 // Change the priceWhen 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:
<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:
@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:
@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:
@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 _:
$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:
<!-- ❌ 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):
<div @signals(['userId_' => auth()->id()])>Hyper:
- Encrypts the value using Laravel's
Cryptfacade - 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):
<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
HyperSignalTamperedExceptionif they don't match - Allows the request to proceed only if validation passes
3. If Tampering is Detected:
// 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:
@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:
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:
@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:
<div @signals([
'userId_' => auth()->id(), // Locked - protected
'username' => auth()->user()->name, // Regular - sent to server
'_cartOpen' => false // Local - stays in browser
])>
</div>| Signal Type | Syntax | Sent to Server | Protected from Tampering |
|---|---|---|---|
| Regular | username | ✅ Yes | ❌ No |
| Local | _cartOpen | ❌ No | N/A (never sent) |
| Locked | userId_ | ✅ Yes | ✅ Yes |
Reading Locked Signals
In your controllers, read locked signals the same way as regular signals:
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:
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:
- Stores the new encrypted value in the session
- Sends the new value to the frontend
- Protects the new value on subsequent requests
Deleting Locked Signals
Use hyper()->forget() to delete locked signals:
public function logout()
{
auth()->logout();
// Remove locked signals
return hyper()->forget(['userId_', 'role_', 'permissions_']);
}When you forget a locked signal:
- It's removed from the frontend (set to
null) - It's removed from the session storage
- Future requests won't validate it
Using null to delete:
// 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
// User tries: $userId_ = 999
// Result: HyperSignalTamperedException thrown✅ DevTools manipulation
// User tries: $$signals.userId_ = 999
// Result: Tampering detected on next request✅ Request payload modification
# User modifies POST body: {"userId_": 999}
# Result: Exception thrownWhat 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.
<!-- 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:
// ✅ 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 largeSession 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:
- User modified the value in DevTools
- Session was cleared but frontend still has the signal
- Multiple tabs with different session states
Solution:
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
<!-- ❌ 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:
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
| Feature | Regular Signals | Locked Signals |
|---|---|---|
| Client-side tampering | ❌ Vulnerable | ✅ Protected |
| Session storage | ❌ No | ✅ Yes (encrypted) |
| Memory overhead | Low | Higher |
| Creation syntax | Any method | Must use @signals |
| Use case | Form data, UI state | Sensitive data |
vs Local Signals
| Feature | Local Signals (_prefix) | Locked Signals (suffix_) |
|---|---|---|
| Sent to server | ❌ No | ✅ Yes |
| Protected | N/A | ✅ Yes |
| Session storage | ❌ No | ✅ Yes |
| Use case | UI state | Protected 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
- Signals - Understanding all signal types
- Blade Integration - Using
@signalsdirective - Validation - Validating signal data

