PHPackages                             arkham-district/secure-tokens - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. arkham-district/secure-tokens

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

arkham-district/secure-tokens
=============================

Laravel package for API authentication using Ed25519 asymmetric cryptography

v1.0.1(1mo ago)060MITPHPPHP ^8.2CI passing

Since Feb 4Pushed 1mo agoCompare

[ Source](https://github.com/arkham-district/secure-tokens)[ Packagist](https://packagist.org/packages/arkham-district/secure-tokens)[ RSS](/packages/arkham-district-secure-tokens/feed)WikiDiscussions main Synced 1mo ago

READMEChangelog (2)Dependencies (12)Versions (3)Used By (0)

Secure Tokens
=============

[](#secure-tokens)

[![Tests](https://github.com/arkham-district/secure-tokens/actions/workflows/tests.yml/badge.svg)](https://github.com/arkham-district/secure-tokens/actions/workflows/tests.yml)[![Latest Version on Packagist](https://camo.githubusercontent.com/f1cea31cdad33e3a581a5de306e7d6da6e20647fe4ddbfb8d420325ac638180d/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f61726b68616d2d64697374726963742f7365637572652d746f6b656e732e737667)](https://packagist.org/packages/arkham-district/secure-tokens)[![License](https://camo.githubusercontent.com/d3b9f926e356a81cc5861346a8467203b7b0d30b3ffdbe1327008007c4f82844/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f61726b68616d2d64697374726963742f7365637572652d746f6b656e732e737667)](https://packagist.org/packages/arkham-district/secure-tokens)

API key authentication for Laravel using **Ed25519 asymmetric cryptography**. A secure alternative to Sanctum with clean token formats and optional request signing.

Table of Contents
-----------------

[](#table-of-contents)

- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
    - [Setup the User Model](#setup-the-user-model)
    - [Creating API Keys](#creating-api-keys)
    - [Protecting Routes](#protecting-routes)
    - [Making Authenticated Requests](#making-authenticated-requests)
    - [Checking Abilities](#checking-abilities)
    - [Revoking Keys](#revoking-keys)
    - [Listing Keys](#listing-keys)
- [Signed Requests (Ed25519)](#signed-requests-ed25519)
    - [Route Setup](#route-setup)
    - [Client-side Signing (PHP)](#client-side-signing-php)
    - [Client-side Signing (Node.js)](#client-side-signing-nodejs)
    - [Client-side Signing (cURL)](#client-side-signing-curl)
- [API Reference](#api-reference)
    - [Ed25519Service](#ed25519service)
    - [ApiKey Model](#apikey-model)
    - [HasApiKeys Trait](#hasapikeys-trait)
    - [NewApiKey DTO](#newapikey-dto)
    - [ApiKeyGuard](#apikeyguard)
    - [AuthenticateApiKey Middleware](#authenticateapikey-middleware)
    - [ValidateSignature Middleware](#validatesignature-middleware)
    - [ApiKeysServiceProvider](#apikeyserviceprovider)
    - [HasApiKeys Contract](#hasapikeys-contract)
    - [Request Macro](#request-macro)
- [Database Schema](#database-schema)
- [Configuration Reference](#configuration-reference)
- [Architecture](#architecture)
    - [Token Format](#token-format)
    - [Authentication Flow](#authentication-flow)
    - [Signature Validation Flow](#signature-validation-flow)
    - [Storage Strategy](#storage-strategy)
- [Comparison with Sanctum](#comparison-with-sanctum)
- [Testing](#testing)
- [License](#license)

Features
--------

[](#features)

- Ed25519 keypair generation (`sk_live_xxx` / `pk_live_xxx`)
- Secret key encrypted at rest, public key for lookups
- Bearer token authentication (simple mode)
- Ed25519 request signature validation (signed mode)
- Abilities/scopes per token
- Token expiration
- Multiple tokens per user
- `last_used_at` tracking
- Polymorphic relationship (works with any Eloquent model)
- Configurable prefixes and environments

Requirements
------------

[](#requirements)

- PHP 8.2+
- Laravel 11+
- `sodium` PHP extension (built-in since PHP 7.2)

Installation
------------

[](#installation)

```
composer require arkham-district/secure-tokens
```

Publish the config and migration:

```
php artisan vendor:publish --tag=api-keys-config
php artisan vendor:publish --tag=api-keys-migrations
php artisan migrate
```

Add the guard to `config/auth.php`:

```
'guards' => [
    // ...
    'api-key' => [
        'driver' => 'api-key',
    ],
],
```

Configuration
-------------

[](#configuration)

The configuration file is published to `config/api-keys.php`:

```
return [
    // Token prefixes (appear in the generated keys)
    'prefix' => [
        'secret' => env('API_KEYS_SECRET_PREFIX', 'sk'),
        'public' => env('API_KEYS_PUBLIC_PREFIX', 'pk'),
    ],

    // Valid environment identifiers
    'environments' => ['live', 'test'],

    // Default expiration in minutes (null = never expires)
    'expiration' => null,

    // Require Ed25519 signature on all requests (global toggle)
    'require_signature' => false,
];
```

Usage
-----

[](#usage)

### Setup the User Model

[](#setup-the-user-model)

Add the trait and interface to any Eloquent model that should own API keys:

```
use ArkhamDistrict\ApiKeys\Contracts\HasApiKeys as HasApiKeysContract;
use ArkhamDistrict\ApiKeys\HasApiKeys;

class User extends Authenticatable implements HasApiKeysContract
{
    use HasApiKeys;
}
```

### Creating API Keys

[](#creating-api-keys)

```
// Create with default abilities (wildcard) and no expiration
$apiKey = $user->createApiKey('Production', 'live');

// Access the keys (only available at creation time)
$apiKey->secretKey; // "sk_live_xxxxx" — give this to the client
$apiKey->publicKey; // "pk_live_xxxxx"
$apiKey->apiKey;    // The persisted ApiKey Eloquent model

// Create with specific abilities
$apiKey = $user->createApiKey('Read Only', 'live', ['invoices:read', 'customers:read']);

// Create with expiration
$apiKey = $user->createApiKey('Temp Key', 'test', ['*'], now()->addDays(30));
```

> **Important:** The full prefixed secret key (`sk_live_xxxxx`) is only returned at creation time. It cannot be retrieved later because only the raw key (without prefix) is stored encrypted in the database.

### Protecting Routes

[](#protecting-routes)

**Option 1 — Laravel's built-in auth middleware** (uses the guard name):

```
Route::middleware('auth:api-key')->group(function () {
    Route::get('/invoices', [InvoiceController::class, 'index']);
});
```

**Option 2 — Package middleware alias** (standalone, does not rely on `config/auth.php`):

```
Route::middleware('auth.api-key')->group(function () {
    Route::get('/invoices', [InvoiceController::class, 'index']);
});
```

Both approaches return a `401 Unauthenticated` JSON response for invalid or missing tokens.

### Making Authenticated Requests

[](#making-authenticated-requests)

```
curl -H "Authorization: Bearer sk_live_xxxxx" https://api.example.com/invoices
```

### Checking Abilities

[](#checking-abilities)

```
Route::middleware('auth:api-key')->get('/invoices', function (Request $request) {
    $apiKey = $request->apiKey();

    if ($apiKey->can('invoices:read')) {
        // The key has the "invoices:read" ability
    }

    if ($apiKey->cant('invoices:delete')) {
        abort(403, 'Insufficient permissions.');
    }

    // Keys with ["*"] abilities pass all checks
});
```

### Revoking Keys

[](#revoking-keys)

```
// Revoke a specific key by ID
$user->apiKeys()->where('id', $keyId)->delete();

// Revoke all keys for a user
$user->apiKeys()->delete();
```

### Listing Keys

[](#listing-keys)

```
$keys = $user->apiKeys()->get();

foreach ($keys as $key) {
    $key->id;           // 1
    $key->name;         // "Production"
    $key->prefix;       // "sk_live_"
    $key->abilities;    // ["invoices:read", "invoices:write"]
    $key->last_used_at; // 2024-01-15 10:30:00
    $key->expires_at;   // null (never expires)
    $key->created_at;   // 2024-01-01 00:00:00
}
```

Signed Requests (Ed25519)
-------------------------

[](#signed-requests-ed25519)

For higher security, require clients to sign the request body with their Ed25519 secret key and include the signature in the `X-Signature` header.

### Route Setup

[](#route-setup)

```
Route::middleware(['auth:api-key', 'verify-signature'])->post('/payments', function () {
    // The request body has been cryptographically verified
});
```

> **Note:** Always place `auth:api-key` before `verify-signature` so the API key is resolved before the signature is validated.

### Client-side Signing (PHP)

[](#client-side-signing-php)

```
$body = json_encode(['amount' => 100, 'currency' => 'USD']);

// The raw secret key is the part after the prefix: sk_live_{THIS_PART}
$rawSecretKey = 'base64_encoded_secret_key';
$secretKeyBin = sodium_base642bin($rawSecretKey, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
$signature = sodium_crypto_sign_detached($body, $secretKeyBin);
$signatureB64 = sodium_bin2base64($signature, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);

$response = Http::withHeaders([
    'Authorization' => 'Bearer sk_live_xxxxx',
    'X-Signature' => $signatureB64,
])->withBody($body, 'application/json')->post('https://api.example.com/payments');
```

### Client-side Signing (Node.js)

[](#client-side-signing-nodejs)

```
const nacl = require('tweetnacl');

const body = JSON.stringify({ amount: 100, currency: 'USD' });
const rawSecretKey = Buffer.from('base64_encoded_secret_key', 'base64url');
const signature = nacl.sign.detached(Buffer.from(body), rawSecretKey);
const signatureB64 = Buffer.from(signature).toString('base64url');

const response = await fetch('https://api.example.com/payments', {
    method: 'POST',
    headers: {
        'Authorization': 'Bearer sk_live_xxxxx',
        'X-Signature': signatureB64,
        'Content-Type': 'application/json',
    },
    body,
});
```

### Client-side Signing (cURL)

[](#client-side-signing-curl)

```
# Assuming $SIGNATURE is pre-computed
curl -X POST https://api.example.com/payments \
  -H "Authorization: Bearer sk_live_xxxxx" \
  -H "X-Signature: $SIGNATURE" \
  -H "Content-Type: application/json" \
  -d '{"amount": 100, "currency": "USD"}'
```

---

API Reference
-------------

[](#api-reference)

### Ed25519Service

[](#ed25519service)

**Namespace:** `ArkhamDistrict\ApiKeys\Services\Ed25519Service`

Service for Ed25519 cryptographic operations. Registered as a singleton in the container.

MethodParametersReturnsDescription`generateKeypair()`—`array{secret_key: string, public_key: string}`Generate a raw Ed25519 keypair. Keys are URL-safe Base64 encoded.`generatePrefixedKeypair()``string $secretPrefix`, `string $publicPrefix`, `string $environment``array{secret_key: string, public_key: string, raw_secret_key: string, raw_public_key: string}`Generate a keypair with human-readable prefixes (e.g., `sk_live_xxx`).`sign()``string $message`, `string $base64SecretKey``string`Sign a message, returns Base64 detached signature.`verify()``string $message`, `string $base64Signature`, `string $base64PublicKey``bool`Verify a detached signature. Returns `false` on any failure.**Example:**

```
$service = app(Ed25519Service::class);

// Generate keypair
$keypair = $service->generateKeypair();
// ['secret_key' => 'base64...', 'public_key' => 'base64...']

// Generate prefixed keypair
$prefixed = $service->generatePrefixedKeypair('sk', 'pk', 'live');
// ['secret_key' => 'sk_live_xxx', 'public_key' => 'pk_live_xxx', 'raw_secret_key' => '...', 'raw_public_key' => '...']

// Sign and verify
$signature = $service->sign('payload', $keypair['secret_key']);
$valid = $service->verify('payload', $signature, $keypair['public_key']); // true
```

---

### ApiKey Model

[](#apikey-model)

**Namespace:** `ArkhamDistrict\ApiKeys\Models\ApiKey`

Eloquent model representing an API key in the `api_keys` table.

#### Properties

[](#properties)

PropertyTypeDescription`$id``int`Primary key.`$tokenable_type``string`Polymorphic owner class (e.g., `App\Models\User`).`$tokenable_id``int`Polymorphic owner ID.`$name``string`Human-readable label.`$prefix``string`Token prefix with environment (e.g., `sk_live_`).`$secret_key``string`Ed25519 secret key (auto-encrypted/decrypted via Laravel).`$public_key``string`Ed25519 public key (plain text, unique).`$abilities``array|null`JSON array of granted abilities. `null` or `["*"]` = all.`$last_used_at``Carbon|null`Last authenticated request timestamp.`$expires_at``Carbon|null`Expiration timestamp. `null` = never.`$created_at``Carbon`Creation timestamp.`$updated_at``Carbon`Last update timestamp.#### Casts

[](#casts)

AttributeCast`abilities``array` (JSON)`secret_key``encrypted``last_used_at``datetime``expires_at``datetime`#### Methods

[](#methods)

MethodParametersReturnsDescription`tokenable()`—`MorphTo`Polymorphic relationship to the owning model.`isExpired()`—`bool``true` if `expires_at` is set and in the past.`can()``string $ability``bool``true` if the ability is granted (or wildcard).`cant()``string $ability``bool`Inverse of `can()`.**Example:**

```
$apiKey = $user->apiKeys()->first();

$apiKey->isExpired();              // false
$apiKey->can('invoices:read');     // true
$apiKey->cant('invoices:delete');  // true
$apiKey->tokenable;                // User model instance
```

---

### HasApiKeys Trait

[](#hasapikeys-trait)

**Namespace:** `ArkhamDistrict\ApiKeys\HasApiKeys`

Trait to be used on Eloquent models to enable API key management.

MethodParametersReturnsDescription`apiKeys()`—`MorphMany`Polymorphic relationship returning all API keys for the model.`createApiKey()``string $name`, `string $environment = 'live'`, `array $abilities = ['*']`, `?DateTimeInterface $expiresAt = null``NewApiKey`Generate an Ed25519 keypair, persist it, and return the DTO.#### `createApiKey()` Parameters

[](#createapikey-parameters)

ParameterTypeDefaultDescription`$name``string`*(required)*Human-readable label (e.g., "Production").`$environment``string``'live'`Environment identifier (e.g., `live`, `test`).`$abilities``array``['*']`Granted abilities. `['*']` grants all.`$expiresAt``?DateTimeInterface``null`Expiration datetime. Falls back to `config('api-keys.expiration')`.**Example:**

```
// All API keys for the user
$user->apiKeys()->get();
$user->apiKeys()->count();
$user->apiKeys()->where('name', 'Production')->first();

// Create new key
$new = $user->createApiKey('My Key', 'live', ['read', 'write'], now()->addDays(90));
```

---

### NewApiKey DTO

[](#newapikey-dto)

**Namespace:** `ArkhamDistrict\ApiKeys\NewApiKey`

Read-only Data Transfer Object returned by `createApiKey()`.

PropertyTypeDescription`$apiKey``ApiKey`The persisted Eloquent model.`$secretKey``string`Full prefixed secret key (e.g., `sk_live_xxxxx`). **Only available at creation time.**`$publicKey``string`Full prefixed public key (e.g., `pk_live_xxxxx`).---

### ApiKeyGuard

[](#apikeyguard)

**Namespace:** `ArkhamDistrict\ApiKeys\Guards\ApiKeyGuard`

Custom authentication guard implementing Laravel's `Illuminate\Contracts\Auth\Guard` interface.

MethodParametersReturnsDescription`check()`—`bool``true` if a valid API key was provided.`guest()`—`bool``true` if no valid API key was provided.`user()`—`?Authenticatable`Resolve and return the authenticated user, or `null`.`id()`—`int|string|null`Get the authenticated user's ID.`validate()``array $credentials``bool`Always returns `false` (not supported).`hasUser()`—`bool``true` if a user has been resolved or manually set.`setUser()``Authenticatable $user``static`Manually set the authenticated user.`getApiKey()`—`?ApiKey`Get the resolved ApiKey model for the current request.**Accessing the guard:**

```
$guard = auth('api-key');
$guard->check();      // bool
$guard->user();       // User|null
$guard->id();         // int|null
$guard->getApiKey();  // ApiKey|null
```

---

### AuthenticateApiKey Middleware

[](#authenticateapikey-middleware)

**Namespace:** `ArkhamDistrict\ApiKeys\Middleware\AuthenticateApiKey`

**Alias:** `auth.api-key`

MethodParametersReturnsDescription`handle()``Request $request`, `Closure $next``Response`Authenticate via `api-key` guard. Returns 401 JSON on failure.**Response on failure:**

```
{ "message": "Unauthenticated." }
```

---

### ValidateSignature Middleware

[](#validatesignature-middleware)

**Namespace:** `ArkhamDistrict\ApiKeys\Middleware\ValidateSignature`

**Alias:** `verify-signature`

MethodParametersReturnsDescription`handle()``Request $request`, `Closure $next``Response`Validate `X-Signature` header against request body using Ed25519.**Response on failure:**

ScenarioStatusBodyMissing `X-Signature` header401`{ "message": "Missing X-Signature header." }`No authenticated API key401`{ "message": "Unauthenticated." }`Invalid signature401`{ "message": "Invalid signature." }`---

### ApiKeysServiceProvider

[](#apikeysserviceprovider)

**Namespace:** `ArkhamDistrict\ApiKeys\ApiKeysServiceProvider`

Auto-discovered via `composer.json` `extra.laravel.providers`.

#### Registration Phase (`register()`)

[](#registration-phase-register)

- Merges default config from `config/api-keys.php`.
- Binds `Ed25519Service` as a singleton.

#### Boot Phase (`boot()`)

[](#boot-phase-boot)

Internal MethodDescription`registerMigrations()`Auto-loads migrations from `database/migrations/`.`registerPublishing()`Registers `api-keys-config` and `api-keys-migrations` publish tags.`registerGuard()`Extends Laravel Auth with the `api-key` driver.`registerMiddleware()`Registers `auth.api-key` and `verify-signature` middleware aliases.`registerRequestMacro()`Adds the `apiKey()` macro to `Illuminate\Http\Request`.#### Publish Tags

[](#publish-tags)

TagDescriptionCommand`api-keys-config`Configuration file`php artisan vendor:publish --tag=api-keys-config``api-keys-migrations`Migration files`php artisan vendor:publish --tag=api-keys-migrations`---

### HasApiKeys Contract

[](#hasapikeys-contract)

**Namespace:** `ArkhamDistrict\ApiKeys\Contracts\HasApiKeys`

Interface that models should implement alongside the `HasApiKeys` trait.

MethodParametersReturns`apiKeys()`—`MorphMany``createApiKey()``string $name`, `string $environment`, `array $abilities = ['*']`, `?DateTimeInterface $expiresAt = null``NewApiKey`---

### Request Macro

[](#request-macro)

The package registers an `apiKey()` macro on `Illuminate\Http\Request`:

```
$request->apiKey(): ?ApiKey
```

Returns the `ApiKey` model for the current authenticated request, or `null` if unauthenticated.

---

Database Schema
---------------

[](#database-schema)

The `api_keys` table:

ColumnTypeNullableDescription`id``bigint` (auto-increment)NoPrimary key.`tokenable_type``string`NoPolymorphic model class.`tokenable_id``bigint`NoPolymorphic model ID.`name``string`NoHuman-readable label.`prefix``string(10)`NoToken prefix (e.g., `sk_live_`).`secret_key``text`NoEncrypted Ed25519 secret key.`public_key``string` (unique)NoPlain text Ed25519 public key.`abilities``text` (JSON)YesArray of granted abilities.`last_used_at``timestamp`YesLast usage timestamp.`expires_at``timestamp`YesExpiration timestamp.`created_at``timestamp`NoCreation timestamp.`updated_at``timestamp`NoUpdate timestamp.**Indexes:**

- Primary key on `id`.
- Composite index on `(tokenable_type, tokenable_id)` (via `morphs()`).
- Unique index on `public_key`.

---

Configuration Reference
-----------------------

[](#configuration-reference)

KeyTypeDefaultEnv VariableDescription`prefix.secret``string``'sk'``API_KEYS_SECRET_PREFIX`Prefix for secret keys.`prefix.public``string``'pk'``API_KEYS_PUBLIC_PREFIX`Prefix for public keys.`environments``array``['live', 'test']`—Valid environment identifiers.`expiration``int|null``null`—Default expiration in minutes. `null` = never.`require_signature``bool``false`—Global signature requirement toggle.---

Architecture
------------

[](#architecture)

### Token Format

[](#token-format)

Tokens follow the format `{prefix}_{environment}_{base64_key}`:

```
sk_live_   (secret key)
pk_live_   (public key)
sk_test_   (test secret key)

```

- **Secret key (`sk`)**: Used by the client for authentication (Bearer token) and request signing.
- **Public key (`pk`)**: Used for signature verification. Stored in plain text for database lookups.

### Authentication Flow

[](#authentication-flow)

```
Client                          Server
  |                               |
  |  GET /api/invoices            |
  |  Authorization: Bearer sk_*   |
  |------------------------------>|
  |                               |-- Extract Bearer token
  |                               |-- Validate format (regex)
  |                               |-- Query by prefix (sk_live_)
  |                               |-- Decrypt & compare secret key
  |                               |-- Check expiration
  |                               |-- Update last_used_at
  |                               |-- Resolve tokenable (User)
  |                               |
  |  200 OK                       |
  ||
  |                               |-- Authenticate via Bearer token
  |                               |-- Extract X-Signature header
  |                               |-- Get API key's public key
  |                               |-- Verify: Ed25519(body, sig, pk)
  |                               |
  |  200 OK                       |
  |
