northstar / cipher-laravel
Northstar Cipher for Laravel: transparent, batched field-level encryption and blind-index search for Eloquent.
Package info
github.com/Northstar-Technologies-LB/cipher-laravel
pkg:composer/northstar/cipher-laravel
Requires
- php: ^8.2
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- northstar/cipher-php: ^1.1
Requires (Dev)
- laravel/framework: ^11.0|^12.0|^13.0
- laravel/scout: ^10.0|^11.0
- phpunit/phpunit: ^11.5
Suggests
- laravel/scout: Northstar Search: SCOUT_DRIVER=northstar for encrypted full-text search (^10.0|^11.0)
Provides
None
Conflicts
None
Replaces
None
README
Encrypt Eloquent attributes with Northstar Cipher: one cast, no cryptography to learn. Values are encrypted before they reach your database, decrypted lazily in batches when you read them, and every failure is explicit, so nothing is ever silently stored or shown in plaintext.
Built on northstar/cipher-php.
Requires PHP 8.2+ (with json, openssl, sodium) and Laravel 11, 12 or 13.
Provisioning: describe your app's keys, one credential per role, blind indexes and
policy rules in a cipher-policy.json and apply it with nsc provision, which writes
the credential secrets (NORTHSTAR_CIPHER_CREDENTIAL, or one variable per role),
endpoint and trust anchor into your env file.
Five-minute setup
composer require northstar/cipher-laravel:^1.1 php artisan cipher:install
Paste the values from the console (Integrations → Laravel) into .env:
NORTHSTAR_CIPHER_ENDPOINT=https://api.northstar.mx NORTHSTAR_CIPHER_CREDENTIAL=nsc_dev_… # shown once when created NORTHSTAR_CIPHER_KEY=default # Production only: pin the attestation verifier # NORTHSTAR_CIPHER_TRUST_ANCHOR=<kid>:<key>
Migration — encrypted columns are text:
Schema::create('todos', function (Blueprint $table) { $table->id(); $table->encrypted('title'); // text + CHECK constraint (PostgreSQL, MySQL ≥ 8.0.16) $table->encrypted('description')->nullable(); $table->timestamps(); });
Model:
use Northstar\Cipher\Laravel\NorthstarEncrypted; class Todo extends Model { protected $casts = [ 'title' => NorthstarEncrypted::class, 'description' => NorthstarEncrypted::class, ]; }
Check everything — connection, attestation, permissions, column types:
php artisan cipher:doctor
That's it. Todo::create([...]), $todo->update([...]), Todo::all(),
$todo->toArray() and JSON responses work as usual.
What happens
- Writes: when a model is saved, all of its encrypted attributes are encrypted in one request, bound to the table, column and primary key. On PostgreSQL, new rows reserve their id from the sequence first, so the INSERT already contains ciphertext. On MySQL/SQLite auto-increment tables the row is inserted with a non-decryptable placeholder and updated with ciphertext immediately after; plaintext is never written in either case.
- Reads: nothing is decrypted at query time. The first time you read an
encrypted attribute, that attribute is decrypted for every model loaded
with it, in one batch request (≤ 1 000 values per request). Listing 100
todos with two encrypted fields makes two requests, not 200. Set
NORTHSTAR_CIPHER_BATCH_SCOPE=modelto decrypt all encrypted attributes of the loaded models in a single request. - Binding: a ciphertext only decrypts for its own record and field. Copying
it to another row (or
replicate()) fails; assign the plaintext again.
Batch decryption needs the credential's Batch decrypt permission
(cipher.decrypt.batch). Without it the package still works, one value per
request, and cipher:doctor tells you.
Encrypting files and large values
Uploads, scans and exports are too large for an encrypted column (1 MiB per value). Encrypt them as objects (protocol §13): the bytes are sealed in your application with a random per-object key, and Cipher encrypts only that 32-byte key, bound to the record. The file never reaches Cipher.
use Illuminate\Support\Facades\Storage; use Northstar\Cipher\Laravel\Facades\Cipher; // migration: $table->encrypted('file_key'); text + the nsc: CHECK, no cast (it is already ciphertext) $ctx = ['collection' => 'documents', 'field' => 'file_key', 'record_id' => (string) $document->id]; $enc = Cipher::encryptObject($request->file('scan')->get(), $ctx); Storage::disk('s3')->put("documents/{$document->id}", $enc->object); $document->update(['file_key' => $enc->wrappedKey]); $bytes = Cipher::withPurpose('support_case', fn () => Cipher::decryptObject( Storage::disk('s3')->get("documents/{$document->id}"), $document->file_key, $ctx, ));
A stolen bucket holds only NSOB ciphertext; opening an object needs its
wrapped key from your database and a decrypt through Cipher (scope, policy,
purpose, audit). A wrong record, another document's key, or a truncated or
altered file raises CipherException INVALID_CIPHERTEXT. cipher:rewrap
does not know about these columns; after a key rotation pass the stored
wrapped keys to Cipher::client()->rewrapMany(...) (the files stay as they
are). Deleting the file and its wrapped key deletes the document; destroying
the key version makes every file whose key it wraps unreadable.
Exact-match search (blind indexes)
protected $casts = [ 'email' => NorthstarEncrypted::class.':searchable,email', // normalization: email ]; // migration $table->encrypted('email'); $table->blindIndex('email'); // email_bidx varchar(64) + index User::cipherWhere('email', $request->email)->first(); User::cipherWhereIn('email', $emails)->get(); User::where('active', true)->orCipherWhere('email', $email)->get();
Normalizations: exact, case_insensitive, email, alphanumeric
(document numbers), digits (phone numbers). The credential needs
cipher.index and cipher.search. In production environments, enable the
field in the console (Search indexes) first.
Search limitations. Only equality works. where('email', 'like', …),
ranges, orderBy and aggregates on encrypted attributes cannot work, because
the database only holds ciphertext. Blind indexes reveal which rows share a
value (equality and frequency), so index high-entropy fields (emails, document
numbers) — not booleans, statuses or countries.
Full-text search (Northstar Search, Laravel Scout)
Typo-tolerant, prefix, phrase, filtered, sorted and faceted search over encrypted attributes, computed inside the attested enclave over encrypted indexes.
composer require laravel/scout echo "SCOUT_DRIVER=northstar" >> .env
use Northstar\Cipher\Laravel\Search\NorthstarSearchable; class Customer extends Model { use NorthstarSearchable; // includes Laravel\Scout\Searchable protected $casts = ['name' => NorthstarEncrypted::class, 'email' => NorthstarEncrypted::class]; // Optional — inferred from casts/fillable otherwise (passwords and tokens are never indexed). public function searchableSchema(): array { return ['language' => 'spanish', 'fields' => [ 'name' => ['type' => 'text', 'boost' => 2], 'email' => 'keyword', 'city' => 'keyword', 'balance' => 'number', 'created_at' => 'date', ]]; } } Customer::search('ana garsia') ->where('city', 'CDMX') ->where('balance', '>=', 1000) ->whereIn('tier', ['gold', 'platinum']) ->whereNotIn('segment', ['gobierno']) ->options(['highlight' => ['name'], 'facets' => ['city']]) ->orderBy('balance', 'desc') ->paginate(20); $customer->scoutMetadata()['_highlights']['name']; // "Ana <mark>García</mark>" (HTML-escaped)
- Saving, updating and deleting models keeps the index in sync (Scout's model events, sync or queued). Encrypted attributes are sent as the ciphertext already in the row; the enclave decrypts them to index them — no extra decrypt round trip.
- Writes bypassing Eloquent (raw SQL, mass
update()) do not fire model events: re-sync withphp artisan cipher:search:rebuild. - Existing rows:
php artisan scout:import "App\Models\Customer". - Turning search on in a running app: queue workers read
SCOUT_DRIVERonly when they start. WithSCOUT_QUEUEon,scout:importonly queues the work, and a worker still running with the old driver drops those jobs without an error: the index stays empty and nothing fails. Backfill withphp artisan cipher:search:rebuild "App\Models\Customer"(writes directly, prints the document count), thenphp artisan queue:restartso later saves are indexed. Check withphp artisan cipher:search:status. Records the enclave cannot index are logged as warnings: keepLOG_LEVELatwarningor lower. - Schema change:
php artisan cipher:search:rebuild "App\Models\Customer"builds a new version next to the live one and switches when done (zero downtime).php artisan cipher:search:statuslists indexes. - Config (
northstar-cipher.search):language,key(key protecting new indexes),wait(NORTHSTAR_SEARCH_WAIT=truemakes writes searchable before they return — tests),indexes(schemas by index name). - Credentials need
cipher.index(writes) andcipher.search(queries);select/highlightand indexing ciphertext also needcipher.decrypt.
Existing data, rotation, reindexing
php artisan cipher:migrate "App\Models\User" # encrypt existing plaintext + backfill indexes (resumable) php artisan cipher:rewrap # after rotating a key in the console php artisan cipher:reindex # after rotating an index key or making a field searchable
Migrating an existing column: change it to text in a migration, add the cast,
run php artisan cipher:migrate, then add the database guard in a follow-up
migration with $table->cipherConstraint('notes').
Options
'ssn' => NorthstarEncrypted::class.':key=pii', // a different key alias 'email' => NorthstarEncrypted::searchable('email', key: 'pii'), // in a casts() method
Declare an access purpose for audit and policy:
use Northstar\Cipher\Laravel\Facades\Cipher; Cipher::withPurpose('support_case', fn () => $customer->ssn);
Direct use: Cipher::encrypt($value, ['collection' => 'notes', 'field' => 'body', 'record_id' => '42']),
Cipher::decrypt(...), Cipher::client()->decryptMany(...).
Failure behaviour
| Situation | Result |
|---|---|
| Cipher unreachable on save | CipherException NETWORK_ERROR; nothing is written |
| Credential missing/malformed/revoked | CONFIGURATION_ERROR / AUTHENTICATION_FAILED |
| Permission revoked | PERMISSION_DENIED on the next read |
| Corrupted or moved ciphertext | INVALID_CIPHERTEXT on read |
| Column contains plaintext | INVALID_CIPHERTEXT (“run cipher:migrate”) |
saveQuietly(), withoutEvents(), query-builder insert/update |
refused (exception or database CHECK); use Eloquent saves |
Render them as you like, for example in bootstrap/app.php:
$exceptions->render(fn (\Northstar\Cipher\CipherException $e) => response()->view('errors.cipher', ['error' => $e], 503));
Security notes
- Do not use Cipher for passwords — keep
Hash::make(). - Query logs and exceptions only ever contain ciphertext; pending plaintext refuses to be serialized, JSON-encoded or cast to string.
- Access tokens and session keys are cached (encrypted with
APP_KEY) so workers don't re-attest per request; they protect transport only. - Encrypted attributes are returned as plain strings to your code — hide them
from API resources (
$hidden) where they shouldn't be exposed.
Transfer between environments
cipher:transfer moves cast-managed fields to another environment, regenerates
blind indexes and verifies each destination value before an atomic database
update. It supports dry runs, resumable progress, verification and rollback.
It requires a prepared destination and a stopped-writer maintenance window;
it leaves maintenance enabled for the deployment configuration switch.
php artisan cipher:transfer --name=dev-to-production --to=/secure/cipher-production.json --dry-run php artisan cipher:transfer --name=dev-to-production --to=/secure/cipher-production.json --maintenance
php artisan cipher:transfer --help lists the options (destination client
file, explicit source configuration, resume, verification and rollback). This
transfers ciphertext to new keys; cipher:rewrap only rotates versions of an
existing key.
Partitions (tenant-scoped authorization)
Give a model a cipherPartition() hook to bind every encrypted attribute of a
record to its tenant/organization. Independent data grants can then be scoped
to partitions instead of exact record IDs.
class Invoice extends Model { protected $casts = ['total' => NorthstarEncrypted::class]; public function cipherPartition(): ?string { return $this->organization_id === null ? null : 'org_'.$this->organization_id; } }
- Compute the partition from plaintext columns only.
nullmeans "no partition" (readable only by unrestricted principals). - Changing the partition columns on save re-encrypts every encrypted attribute of the record under the new partition (ciphertext cannot be relabeled).
cipher:migrate,cipher:rewrap,cipher:reindex,cipher:transferand Scout use the partition automatically; Scout indexes of such models arepartitioned(a schema change:cipher:search:rebuild).- Blind-index queries (
whereEncrypted) need an unrestricted partition grant; partition-scoped users search through Scout instead.
Existing rows were encrypted without a partition. Adopt the hook in a maintenance window:
php artisan down # deploy the code with cipherPartition() php artisan cipher:relabel --name=invoices-to-orgs --dry-run php artisan cipher:relabel --name=invoices-to-orgs --maintenance php artisan cipher:relabel --name=invoices-to-orgs --verify php artisan cipher:search:rebuild "App\Models\Invoice" php artisan up
cipher:relabel uses the cipher:transfer engine: each row is decrypted under
its stored context, encrypted under its partition, blind indexes are
regenerated, the result is verified, and row + journal commit together. It is
resumable (same --name), skips rows already in their partition, and
--rollback restores the journaled originals (then deploy the code without the
hook before reopening). A model may define cipherPreviousPartition() to
relabel from an older partition scheme.
Independent data authorization
When the environment requires independent authorization (every data operation also needs a short-lived grant from an identity provider independent of the app):
// config/northstar-cipher.php → 'authorization' NORTHSTAR_CIPHER_AUTHORIZATION_EXCHANGE_URL=https://CONTROL_PLANE/api/v1/authorization/exchange NORTHSTAR_CIPHER_AUTHORIZATION_AUTHORITY=customer-auth NORTHSTAR_CIPHER_AUTHORIZATION_FILE=/run/cipher/app-worker.grant # artisan + queue workers Route::middleware(['auth', 'cipher.authorize'])->group(function () { /* ... */ }); class SendInvoice implements ShouldQueue { public function middleware(): array { return [new WithCipherServiceAuthorization]; } }
cipher.authorizeresolves the caller's identity token withauthorization.identity_token_resolver(default: bearer token; implementAuthorization\IdentityTokenResolverfor sessions/cookies), exchanges it once per request (lazily) and runs the request with the grant. Requests without a token pass through without a grant (missing_identity=passthrough, an enforcing environment then denies data access) or get a 401 (deny). If the exchange refuses the identity or fails, the request continues without a grant (off/report allow, enforce denies), a warning is logged without any token, andNorthstar\Cipher\Laravel\Events\AuthorizationExchangeFailedis dispatched for your own alerting. SetNORTHSTAR_CIPHER_AUTHORIZATION_EXCHANGE_FAILURE=failto fail fast instead.- Workers and artisan read the service grant file written by
nsc identity-agent; it is re-read for every operation and fails closed when missing or expired. Cipher::withAuthorizationProvider($provider, fn () => ...)andCipher::withAuthorizationFile($path, fn () => ...)scope any block.- Scout index writes (saves, deletes, rebuilds) run as the service identity:
set
NORTHSTAR_CIPHER_SERVICE_AUTHORIZATION_FILE(defaults toNORTHSTAR_CIPHER_AUTHORIZATION_FILE) or bind aGrantProvidertoCipherManager::SERVICE_GRANT. Users scoped to one partition can then save rows that still get indexed; searches use the user's grant. The service needsindexon the search index and on the table (every field, record and partition). Without a service grant, writes use the current client. - Search scopes must name the Scout index (
searchableAs(), includingSCOUT_PREFIX), not the table: tablememberswith prefixapp_is searched asapp_members. The search service needsindexon both the table (the stored ciphertext's context) and the index.cipher:authorization:checkwarns when a search scope names the table. - Check an environment:
php artisan cipher:authorization:check --authorization-file=/run/cipher/check.grant --partition=org_1(allowed round trip; no grant, tampered grant, other record and other partition must be denied). Give the identity a binding for the probe collectionnsc_authorization_check.
Tests
composer install vendor/bin/phpunit tests
The tests boot a Laravel container with SQLite and an in-process fake of the Cipher API; they need no network access or credentials.
Versioning
The package follows Semantic Versioning;
northstar/cipher-laravel and northstar/cipher-php are released together with
the same version number. Changes are listed in CHANGELOG.md.
Security
Please report vulnerabilities privately through GitHub's private vulnerability reporting, not in a public issue.
License
Apache License 2.0; see LICENSE and NOTICE. "Northstar" and "Northstar Cipher" are trademarks of Northstar Technologies and are not licensed under the Apache License.