sokkian / simpleauth
A lightweight, secure PHP library for passwordless authentication using magic links
Requires
- php: >=7.4
- ext-pdo: *
- ext-pdo_mysql: *
README
A lightweight, secure PHP library for passwordless authentication using magic links (one-time login tokens).
Project Status
⚠️ Learning/educational project - not actively maintained. Feel free to fork and adapt it for your needs.
Features
- Passwordless authentication: Secure login via email magic links
- Replay protection: One-time use tokens with nonce validation
- Clock skew tolerance: Configurable grace period for time synchronization
- Immutable result objects: Type-safe error handling without exceptions
- Internationalization: Built-in support for multiple languages
- Zero dependencies: Pure PHP with no external libraries required
- PSR-4 compatible: Easy integration via autoloading
Requirements
- PHP 7.4 or higher
- PDO extension with MySQL support
- MySQL 5.7+ or MariaDB 10.2+
Installation
Via Composer (Recommended)
composer require sokkian/simpleauth
Manual Installation
- Download or clone this repository
- Copy the
src/directory to your project:
your-project/
├── src/
│ ├── Token.php
│ ├── Verifier.php
│ ├── Result.php
│ └── locales/
│ ├── en_US.php
│ ├── es_ES.php
│ └── it_IT.php
└── autoload.php
- Create an autoloader file in your project root:
autoload.php:
<?php /** * SimpleAuth Autoloader * * PSR-4 autoloader for manual installation */ spl_autoload_register(function ($class) { $prefix = 'SimpleAuth\\'; $base_dir = __DIR__ . '/src/'; $len = strlen($prefix); if (strncmp($prefix, $class, $len) !== 0) { return; } $relative_class = substr($class, $len); $file = $base_dir . str_replace('\\', '/', $relative_class) . '.php'; if (file_exists($file)) { require $file; } });
Database Setup
Run the SQL schema to create required tables:
mysql -u your_user -p your_database < schema.sql
Or manually create tables:
CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, email VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(100) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE access_tokens ( id INT PRIMARY KEY AUTO_INCREMENT, token VARCHAR(64) UNIQUE NOT NULL, user_id INT NOT NULL, jti VARCHAR(32) NOT NULL, expires DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_token (token), INDEX idx_expires (expires) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE nonces ( jti VARCHAR(32) PRIMARY KEY, expires DATETIME NOT NULL, consumed_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_expires (expires) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Quick Start
1. Generate a Magic Link
<?php // Autoload (works for both Composer and manual installation) if (file_exists('vendor/autoload.php')) { require_once 'vendor/autoload.php'; } else { require_once 'autoload.php'; } use SimpleAuth\Token; // Database connection $db = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'pass'); // Generate token for user ID 1 $tokenGenerator = new Token($db); $token = $tokenGenerator->generate(1, 900); // 900 seconds = 15 minutes // Build magic link $magicLink = 'https://example.com/auth/verify.php?t=' . $token; // Send via email mail('user@example.com', 'Login Link', "Click to login: $magicLink");
2. Verify the Token
<?php // Autoload (works for both Composer and manual installation) if (file_exists('vendor/autoload.php')) { require_once 'vendor/autoload.php'; } else { require_once 'autoload.php'; } use SimpleAuth\Verifier; session_start(); $db = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'pass'); $verifier = new Verifier($db, 120); // 120 seconds clock skew tolerance $token = $_GET['t'] ?? ''; $result = $verifier->verify($token); if ($result->isOk()) { $_SESSION['user_id'] = $result->getUserId(); header('Location: /dashboard.php'); exit; } else { echo 'Error: ' . $result->getReason(); }
Complete Testing Example
Here's a minimal working example to test the installation:
test-login.php (Request magic link)
<?php // Autoload (works for both Composer and manual installation) if (file_exists('vendor/autoload.php')) { require_once 'vendor/autoload.php'; } else { require_once 'autoload.php'; } use SimpleAuth\Token; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL); if (!$email) { die('Invalid email address'); } try { $db = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'pass'); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // Find user $stmt = $db->prepare("SELECT id, name FROM users WHERE email = ?"); $stmt->execute([$email]); $user = $stmt->fetch(); if (!$user) { // Don't reveal if user exists $message = 'If an account exists, you will receive a login link.'; } else { // Generate token $tokenGenerator = new Token($db); $token = $tokenGenerator->generate($user['id'], 900); // Build magic link $magicLink = 'http://localhost/test-verify.php?t=' . $token; // For testing: display link instead of sending email $message = 'Magic link (for testing): <a href="' . $magicLink . '">' . $magicLink . '</a>'; } } catch (Exception $e) { die('Error: ' . $e->getMessage()); } } ?> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Test SimpleAuth - Login</title> </head> <body> <h1>SimpleAuth Test - Request Login</h1> <?php if (isset($message)): ?> <p><?= $message ?></p> <p><a href="test-login.php">Try another email</a></p> <?php else: ?> <form method="POST"> <label>Email:</label><br> <input type="email" name="email" required><br><br> <button type="submit">Send Magic Link</button> </form> <?php endif; ?> </body> </html>
test-verify.php (Verify token)
<?php // Autoload (works for both Composer and manual installation) if (file_exists('vendor/autoload.php')) { require_once 'vendor/autoload.php'; } else { require_once 'autoload.php'; } use SimpleAuth\Verifier; session_start(); $token = $_GET['t'] ?? ''; if (empty($token)) { die('No token provided'); } try { $db = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'pass'); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $verifier = new Verifier($db, 120); $result = $verifier->verify($token); if ($result->isOk()) { $_SESSION['user_id'] = $result->getUserId(); $success = true; } else { $error = $result->getReason(); } } catch (Exception $e) { die('Error: ' . $e->getMessage()); } ?> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Test SimpleAuth - Verify</title> </head> <body> <h1>SimpleAuth Test - Verification</h1> <?php if (isset($success)): ?> <p><strong>Success!</strong> You are now logged in.</p> <p>User ID: <?= $_SESSION['user_id'] ?></p> <p><a href="test-logout.php">Logout</a></p> <?php else: ?> <p><strong>Authentication Failed</strong></p> <p>Error code: <?= htmlspecialchars($error) ?></p> <p><a href="test-login.php">Try again</a></p> <?php endif; ?> </body> </html>
test-logout.php (Clear session)
<?php session_start(); session_destroy(); ?> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Logged Out</title> </head> <body> <h1>Logged Out</h1> <p>You have been logged out successfully.</p> <p><a href="test-login.php">Login again</a></p> </body> </html>
Testing steps:
- Insert a test user:
INSERT INTO users (email, name) VALUES ('test@example.com', 'Test User'); - Open
test-login.phpin your browser - Enter
test@example.com - Click the magic link displayed
- Verify you're logged in
API Reference
Token Class
__construct(PDO $db)
Create a new token generator.
Parameters:
$db- PDO database connection
generate(int $user_id, int $ttlSeconds = 900): string
Generate a new magic link token.
Parameters:
$user_id- User ID from users table$ttlSeconds- Time to live in seconds (default: 900 = 15 minutes)
Returns: string - The generated token
Example:
$token = $tokenGenerator->generate(123, 600); // 10 minutes
cleanup(int $retentionWeeks = 4): array
Delete expired tokens and nonces.
Parameters:
$retentionWeeks- Keep records for this many weeks after expiration
Returns: array with keys:
tokens_deleted- Number of tokens deletednonces_deleted- Number of nonces deleted
Example:
$stats = $tokenGenerator->cleanup(4); echo "Deleted {$stats['tokens_deleted']} tokens";
Verifier Class
__construct(PDO $db, int $clockSkewSeconds = 120)
Create a new token verifier.
Parameters:
$db- PDO database connection$clockSkewSeconds- Clock skew tolerance in seconds (default: 120)
verify(string $token): Result
Verify a magic link token.
Parameters:
$token- The token to verify
Returns: Result object
Example:
$result = $verifier->verify($token); if ($result->isOk()) { $userId = $result->getUserId(); }
verifyFromUrl(string $url, string $paramName = 't'): Result
Extract and verify token from URL.
Parameters:
$url- Complete URL with token parameter$paramName- Query parameter name (default: 't')
Returns: Result object
Result Class
Constants (Error Codes)
| Constant | Value | Description |
|---|---|---|
TOKEN_NOT_FOUND |
token_not_found |
Token doesn't exist in database |
TOKEN_EXPIRED |
token_expired |
Token has expired |
TOKEN_ALREADY_USED |
token_already_used |
Replay attack detected |
MISSING_TOKEN |
missing_token |
No token provided in URL |
Methods
isOk(): bool
Returns true if verification succeeded.
isFailed(): bool
Returns true if verification failed.
getReason(): ?string
Returns error code (null if success).
getData(): ?array
Returns success data array (null if failed).
getUserId(): ?int
Returns authenticated user ID (null if failed).
Internationalization
SimpleAuth includes translations for error messages in multiple languages.
Supported locales:
en_US- English (United States)es_ES- Spanish (Spain)it_IT- Italian (Italy)
Customizing messages:
Messages are stored in src/locales/{locale}.php. To override messages in your application:
- Create directory:
src/App/locales/simpleauth/ - Create locale file:
src/App/locales/simpleauth/es_ES.php - Override specific messages:
<?php return [ 'token_expired' => 'Tu enlace ha caducado. Solicita uno nuevo.', // Only override what you need ];
See src/locales/README.md for available message IDs.
Security Best Practices
- Always use HTTPS for magic link URLs
- Short TTL: Keep token lifetime short (5-15 minutes recommended)
- Rate limiting: Limit magic link requests per email/IP
- Email validation: Verify email ownership before generating tokens
- Cleanup regularly: Run
cleanup()daily via cron job - Monitor nonces: Alert on unusual replay attack attempts
- Secure sessions: Use secure session configuration after authentication
Maintenance
Cleanup Cron Job
Add to your crontab to run daily cleanup:
0 2 * * * /usr/bin/php /path/to/cleanup.php
cleanup.php:
<?php // Autoload if (file_exists('vendor/autoload.php')) { require_once 'vendor/autoload.php'; } else { require_once 'autoload.php'; } use SimpleAuth\Token; $db = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'pass'); $tokenGenerator = new Token($db); $stats = $tokenGenerator->cleanup(4); echo "Cleanup completed: {$stats['tokens_deleted']} tokens, {$stats['nonces_deleted']} nonces deleted\n";
Troubleshooting
Problem: Token always shows as "not found"
Solution: Check that the token is being passed correctly in the URL parameter
Problem: Token shows as "expired" immediately
Solution: Check server time synchronization. Increase clockSkewSeconds if needed.
Problem: "Token already used" on first attempt
Solution: Check for duplicate requests. Ensure the token isn't being consumed multiple times.
Problem: Database errors
Solution: Verify all tables are created and foreign keys are properly set up.
Problem: Autoloader not working (manual installation)
Solution: Verify autoload.php is in the project root and the src/ path is correct.
License
MIT License - see LICENSE file for details.
Forking and Using This Project
This project is a learning exercise and not actively maintained. You are encouraged to:
- Fork this repository and adapt it for your own projects
- Modify the code to fit your specific requirements
- Use it as a reference for understanding passwordless authentication
- Build upon it and create your own improved versions
If you create something interesting based on this work, feel free to share it (but not required).
Support
For questions, review the documentation above or fork the project to experiment. Limited support available at support@sokkian.net.
Changelog
1.0.0 (2025-01-15)
- Initial release
- Magic link token generation
- Token verification with replay protection
- Multi-language support (en_US, es_ES, it_IT)
- Clock skew tolerance
- PSR-4 autoloading