handshake-auth
v0.2.1
Published
Lightweight, storage-agnostic authentication for Express.js
Maintainers
Readme
Handshake Auth
Lightweight, storage-agnostic authentication for Express.js.
Overview
Handshake Auth is a lightweight authentication library that follows the strategy pattern similar to Passport.js, but without requiring a database connection. You provide callbacks for all storage operations (Inversion of Control), giving you complete control over how accounts are stored and retrieved.
Core Philosophy
"Handshake Auth authenticates requests. It does not log users in."
The library handles the "handshakes" and "proofs" without demanding a seat at your database table. After successful authentication, you receive an Account object and decide what to do with it.
Why Handshake Auth?
vs Passport.js
Passport is the de facto standard but shows its age:
| Passport.js | Handshake Auth |
|-------------|----------------|
| Middleware hides control flow (magic redirects) | Explicit control flow (you call authenticate(), you handle the result) |
| Sessions are the assumed default | Session-agnostic (works with cookie-session, no server-side state) |
| serializeUser/deserializeUser are global singletons | Callbacks are per-instance, not global |
| Callback-based API (done(err, user, info)) | async/await throughout |
| OAuth flows are opaque | OAuth phases are explicit and visible |
| Hundreds of strategies with inconsistent quality | Small, auditable codebase with built-in strategies |
vs better-auth
better-auth is feature-rich but opinionated:
| better-auth | Handshake Auth | |-------------|----------------| | Requires database connection | No database connection - you provide callbacks | | Creates and manages auth tables | You own your schema | | All-in-one (sessions, email verification, etc.) | Auth only - does one thing well | | Great for rapid prototyping | Full control over persistence |
The Sweet Spot
If you want Passport's flexibility with modern ergonomics, without better-auth's database coupling, Handshake Auth is for you.
Features
- Storage-agnostic - You provide callbacks, you own your data
- Express-only - Focused on Express.js with
cookie-session - TypeScript-first - Full type safety with generics
- Modern - async/await throughout, ESM-first
- Strategies included:
- Password (simple password-only for self-hosted apps)
- Username/Password (traditional email + password)
- Magic Link (passwordless via email)
- OAuth: Google, GitHub, Discord, Microsoft, Twitter/X
Installation
npm install handshake-authBasic Usage
import { Handshake, UsernamePasswordStrategy } from 'handshake-auth';
// Define your account type
interface Account {
id: string;
email: string;
passwordHash: string;
}
// Create a Handshake instance with your callbacks
const hs = new Handshake<Account>({
findAccount: async (email) => {
// Your database lookup here
return db.accounts.findByEmail(email);
},
verifyPassword: async (account, password) => {
// Your password verification here (e.g., bcrypt.compare)
return await bcrypt.compare(password, account.passwordHash);
},
});
// Register the username-password strategy
hs.use(new UsernamePasswordStrategy());
// Authenticate
const result = await hs.authenticate('username-password', '[email protected]', 'their-password');
if (result.account) {
// Authentication successful
console.log('Logged in:', result.account.email);
} else {
// Authentication failed
console.log('Error:', result.error);
}Express Integration
Use with Express and cookie-session for a complete authentication setup:
import express from 'express';
import cookieSession from 'cookie-session';
import {
Handshake,
UsernamePasswordStrategy,
handshakeMiddleware,
login,
logout,
requireAuth,
requireGuest,
} from 'handshake-auth';
const app = express();
app.use(express.json());
// Configure cookie-session
app.use(cookieSession({
name: 'session',
keys: [process.env.SESSION_SECRET!],
maxAge: 24 * 60 * 60 * 1000, // 24 hours
}));
// Set up Handshake
const hs = new Handshake<Account>({
findAccount: async (email) => db.accounts.findByEmail(email),
verifyPassword: async (account, password) => bcrypt.compare(password, account.passwordHash),
});
hs.use(new UsernamePasswordStrategy());
// Add middleware (attaches authenticate helper to req)
app.use(handshakeMiddleware(hs));
// Login route
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const result = await hs.authenticate('username-password', email, password);
if (result.account) {
login(req, result.account);
res.json({ success: true });
} else {
res.status(401).json({ error: result.error });
}
});
// Logout route
app.post('/logout', (req, res) => {
logout(req);
res.json({ success: true });
});
// Protected route (API style - returns 401 JSON if not authenticated)
app.get('/api/me', requireAuth(), (req, res) => {
res.json({ accountId: req.session?.accountId });
});
// Protected route (Web style - redirects to /login if not authenticated)
app.get('/dashboard', requireAuth({ redirectTo: '/login' }), (req, res) => {
res.send('Welcome to your dashboard!');
});
// Guest-only route (redirects to /dashboard if already logged in)
app.get('/login', requireGuest({ redirectTo: '/dashboard' }), (req, res) => {
res.send('<form>...</form>');
});Magic Link Authentication
Passwordless authentication via email:
import { Handshake, useMagicLink, login } from 'handshake-auth';
// In-memory token storage (use a database in production)
const tokens = new Map<string, { email: string; expiresAt: Date }>();
const hs = new Handshake<Account>({
findAccount: async (email) => db.accounts.findByEmail(email),
storeMagicToken: async (email, token, expiresAt) => {
tokens.set(token, { email, expiresAt });
},
verifyMagicToken: async (token) => {
const record = tokens.get(token);
if (!record || record.expiresAt < new Date()) return null;
tokens.delete(token); // One-time use
return { email: record.email };
},
});
// Register magic link strategy
useMagicLink(hs, {
baseUrl: 'http://localhost:3000',
sendMagicLink: async (email, token, url) => {
// Send email with the magic link URL
console.log(`Magic link for ${email}: ${url}`);
},
});
// Request magic link
app.post('/auth/magic', async (req, res) => {
const result = await hs.authenticate('magic:send', req.body.email);
if (!result.error) {
res.json({ message: 'Check your email!' });
} else {
res.status(400).json({ error: result.error });
}
});
// Verify magic link
app.get('/auth/magic/callback', async (req, res) => {
const result = await hs.authenticate('magic:verify', req.query.token);
if (result.account) {
login(req, result.account);
res.redirect('/dashboard');
} else {
res.status(401).send('Invalid or expired link');
}
});OAuth Authentication
Handshake Auth supports OAuth providers with a simple, consistent API:
import {
Handshake,
GoogleStrategy,
GitHubStrategy,
DiscordStrategy,
MicrosoftStrategy,
TwitterXStrategy,
login,
} from 'handshake-auth';
const hs = new Handshake<Account>({
findAccount: async (email) => db.accounts.findByEmail(email),
findOrCreateFromOAuth: async (provider, profile) => {
// Find existing account or create new one
let account = await db.accounts.findByProviderId(provider, profile.id);
if (!account) {
account = await db.accounts.create({
email: profile.email,
name: profile.name,
provider,
providerId: profile.id,
});
}
return account;
},
});
// Register OAuth strategies (only the ones you need)
if (process.env.GOOGLE_CLIENT_ID) {
hs.use(new GoogleStrategy({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: 'http://localhost:3000/auth/google/callback',
}));
}
if (process.env.GITHUB_CLIENT_ID) {
hs.use(new GitHubStrategy({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectUri: 'http://localhost:3000/auth/github/callback',
}));
}
// Generic OAuth routes
app.get('/auth/:provider', async (req, res) => {
const result = await hs.authenticate(req.params.provider, req, res, 'redirect');
if ('redirectUrl' in result) {
res.redirect(result.redirectUrl);
} else {
res.status(400).send(result.error);
}
});
app.get('/auth/:provider/callback', async (req, res) => {
const result = await hs.authenticate(req.params.provider, req, res, 'callback');
if (result.account) {
login(req, result.account);
res.redirect('/dashboard');
} else {
res.status(401).send(result.error);
}
});Supported OAuth Providers
| Provider | Strategy | Notes |
|----------|----------|-------|
| Google | GoogleStrategy | OpenID Connect |
| GitHub | GitHubStrategy | Fetches email from /user/emails if needed |
| Discord | DiscordStrategy | Standard OAuth2 |
| Microsoft | MicrosoftStrategy | Supports tenant configuration |
| Twitter/X | TwitterXStrategy | OAuth 2.0 with PKCE (no email provided) |
Documentation
For detailed documentation, see the docs/ folder:
- API Reference - Handshake class and core types
- Express Middleware - Middleware functions, route guards, session helpers
- Strategies:
- Password - Simple password-only authentication
- Username/Password - Email/username + password
- Magic Link - Passwordless email authentication
- Google - Google OAuth
- GitHub - GitHub OAuth
- Discord - Discord OAuth
- Microsoft - Microsoft OAuth
- Twitter/X - Twitter/X OAuth
Example Application
See the examples/express-app directory for a complete working example with all authentication strategies.
cd examples/express-app
npm install
cp .env.example .env
npm run devWhat This Library Does NOT Do
- No account management - No account model, no persistence, no database integrations
- No session management - Uses
cookie-sessionbut doesn't manage server-side sessions - No route ownership - No automatic
/login,/callback, or/logoutroutes - No email sending - Magic links require user-supplied delivery
- No UI - No forms, no opinionated UX
ChangeLog
- 0.2.0 - All strategies implemented (Password, Username/Password, Magic Link, Google, GitHub, Discord, Microsoft, Twitter/X), Express middleware with route guards, comprehensive documentation
- 0.1.0 - Initial development release
License
ISC
