Modern, TypeScript-first authentication middleware for Node.js. Drop-in replacement for passport.js — same API, zero dependencies, async/await native, PKCE by default.
Passport.js has 6.3M weekly downloads and a single maintainer who stopped active development in 2023. With 393 open issues and no modern OAuth features, using passport in a security-critical application means accepting known gaps:
- No PKCE support (authorization code interception attacks)
- No TypeScript (relies on DefinitelyTyped, often out of sync)
- Callback-based internals incompatible with async/await
- Session fixation and state validation bugs unpatched
- 500+ separate strategy packages, each with its own abandoned maintainer
aegis fixes all of this while keeping the same API. Migrating is an import path change.
| Feature | passport.js | aegis |
|---|---|---|
| TypeScript | DefinitelyTyped (external) | Native, full generics |
| API style | Callbacks only | Async/await + callbacks |
| Strategies | 500+ separate packages | Built-in (one install) |
| Dependencies | 3+ per strategy | Zero |
| OAuth PKCE | Not supported | Default for all flows |
| Session security | Manual regeneration | Auto-regenerate on login |
| State parameter | Optional, easy to skip | Enforced by default |
| Framework support | Express only | Express, Fastify, Koa |
| Node.js minimum | Not documented | Node 18+ (LTS) |
npm install @agentine/aegisRequires Node.js 18 or later. No additional strategy packages needed.
import express from 'express';
import session from 'express-session';
import aegis, { LocalStrategy } from '@agentine/aegis';
const app = express();
app.use(express.json());
app.use(session({ secret: 'keyboard cat', resave: false, saveUninitialized: false }));
app.use(aegis.initialize());
app.use(aegis.session());
// Register a strategy
aegis.use(new LocalStrategy(async (username, password) => {
const user = await db.users.findOne({ username });
if (!user || !await user.verifyPassword(password)) return false;
return user;
}));
// Session serialization
aegis.serializeUser(async (user) => user.id);
aegis.deserializeUser(async (id) => db.users.findById(id));
// Login route
app.post('/login', aegis.authenticate('local', {
successRedirect: '/dashboard',
failureRedirect: '/login',
}));
// Protected route
app.get('/dashboard', (req, res) => {
if (!req.isAuthenticated()) return res.redirect('/login');
res.json({ user: req.user });
});
// Logout
app.post('/logout', (req, res) => {
req.logout(() => res.redirect('/login'));
});Migrating from passport is an import path change:
// Before
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as GitHubStrategy } from 'passport-github2';
// After — option 1 (alias, zero code changes)
import passport from '@agentine/aegis';
import { LocalStrategy, GoogleStrategy, GitHubStrategy } from '@agentine/aegis';
// After — option 2 (rename)
import aegis, { LocalStrategy, GoogleStrategy, GitHubStrategy } from '@agentine/aegis';All passport API methods work identically:
passport.use()/passport.unuse()passport.initialize()/passport.session()passport.authenticate()passport.serializeUser()/passport.deserializeUser()req.login()/req.logIn()req.logout()/req.logOut()req.isAuthenticated()/req.isUnauthenticated()failureFlash,successFlash,passReqToCallbackoptions
New in aegis (not in passport):
- Async verify functions return a value instead of calling
done(null, user) - PKCE enabled by default on all OAuth flows
stateparameter enforced by default- Session regeneration on login (prevents session fixation)
Username/password authentication from req.body.
import { LocalStrategy } from '@agentine/aegis';
// Async verify (recommended)
aegis.use(new LocalStrategy(async (username, password) => {
const user = await User.findOne({ username });
if (!user || !await bcrypt.compare(password, user.passwordHash)) return false;
return user;
}));
// Callback verify (passport-compatible)
aegis.use(new LocalStrategy((username, password, done) => {
User.findOne({ username }, (err, user) => {
if (err) return done(err);
if (!user || !user.verifyPassword(password)) return done(null, false, { message: 'Invalid credentials' });
return done(null, user);
});
}));
// Custom field names
aegis.use(new LocalStrategy(
{ usernameField: 'email', passwordField: 'pass' },
async (email, password) => { /* ... */ },
));Options:
| Option | Type | Default | Description |
|---|---|---|---|
usernameField |
string |
'username' |
req.body field for the username |
passwordField |
string |
'password' |
req.body field for the password |
passReqToCallback |
boolean |
false |
Pass req as first argument to the verify function |
Base class for custom OAuth 2.0 providers. All provider strategies extend this.
import { OAuth2Strategy } from '@agentine/aegis';
aegis.use(new OAuth2Strategy(
{
authorizationURL: 'https://provider.example.com/oauth/authorize',
tokenURL: 'https://provider.example.com/oauth/token',
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
callbackURL: 'https://myapp.com/auth/callback',
scope: ['read:user', 'read:email'],
},
async (accessToken, refreshToken, profile) => {
return User.findOrCreate({ providerId: profile.id });
},
));Options:
| Option | Type | Default | Description |
|---|---|---|---|
authorizationURL |
string |
— | Provider authorization endpoint |
tokenURL |
string |
— | Provider token endpoint |
clientID |
string |
— | OAuth client ID |
clientSecret |
string |
— | OAuth client secret |
callbackURL |
string |
— | Your application's redirect URI |
scope |
string | string[] |
— | Requested scopes |
pkce |
boolean |
true |
Enable PKCE (S256) — strongly recommended |
state |
boolean |
true |
Enable CSRF state parameter — strongly recommended |
passReqToCallback |
boolean |
false |
Pass req as first argument to verify |
import { GoogleStrategy } from '@agentine/aegis';
aegis.use(new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback',
// scope defaults to ['openid', 'profile', 'email']
},
async (accessToken, refreshToken, profile) => {
return User.findOrCreate({
googleId: profile.id,
email: profile.emails?.[0]?.value,
displayName: profile.displayName,
});
},
));
app.get('/auth/google', aegis.authenticate('google'));
app.get('/auth/google/callback',
aegis.authenticate('google', { failureRedirect: '/login' }),
(req, res) => res.redirect('/'),
);Profile fields: id, displayName, name.givenName, name.familyName, emails[].value, photos[].value
import { GitHubStrategy } from '@agentine/aegis';
aegis.use(new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/auth/github/callback',
// scope defaults to ['read:user', 'user:email']
},
async (accessToken, refreshToken, profile) => {
return User.findOrCreate({ githubId: profile.id });
},
));
app.get('/auth/github', aegis.authenticate('github'));
app.get('/auth/github/callback',
aegis.authenticate('github', { failureRedirect: '/login' }),
(req, res) => res.redirect('/'),
);Note: GitHub does not always return the email in the main profile. aegis automatically fetches /user/emails and includes verified addresses when available.
import { FacebookStrategy } from '@agentine/aegis';
aegis.use(new FacebookStrategy(
{
clientID: process.env.FACEBOOK_APP_ID,
clientSecret: process.env.FACEBOOK_APP_SECRET,
callbackURL: '/auth/facebook/callback',
scope: ['email', 'public_profile'],
profileFields: ['id', 'name', 'email', 'picture'], // optional
},
async (accessToken, refreshToken, profile) => {
return User.findOrCreate({ facebookId: profile.id });
},
));Uses Twitter API v2 with OAuth 2.0 (not legacy OAuth 1.0a). PKCE is required and always enabled.
import { TwitterStrategy } from '@agentine/aegis';
aegis.use(new TwitterStrategy(
{
clientID: process.env.TWITTER_CLIENT_ID,
clientSecret: process.env.TWITTER_CLIENT_SECRET,
callbackURL: '/auth/twitter/callback',
// scope defaults to ['tweet.read', 'users.read', 'offline.access']
},
async (accessToken, refreshToken, profile) => {
return User.findOrCreate({ twitterId: profile.id });
},
));Note: Twitter API v2 does not return email addresses.
Apple uses JWT-based ID tokens instead of a userinfo endpoint. aegis verifies the JWT signature using Apple's published JWKS (cached for 1 hour), validates issuer/audience/expiry, and enforces nonce-based replay protection.
Apple only sends user name data on the first login. Store it at that point.
import { AppleStrategy } from '@agentine/aegis';
aegis.use(new AppleStrategy(
{
clientID: 'com.example.myapp', // Your App ID / Services ID
clientSecret: process.env.APPLE_CLIENT_SECRET, // Signed JWT (see Apple docs)
callbackURL: '/auth/apple/callback',
scope: ['name', 'email'],
},
async (accessToken, refreshToken, profile) => {
return User.findOrCreate({
appleId: profile.id,
email: profile.emails?.[0]?.value,
// Store name on first login — Apple won't send it again
displayName: profile.displayName || undefined,
});
},
));
// Apple POSTs the callback — use POST route
app.post('/auth/apple/callback',
aegis.authenticate('apple', { failureRedirect: '/login' }),
(req, res) => res.redirect('/'),
);Note: The Apple clientSecret is a signed JWT, not a simple string. See Apple's documentation for how to generate it.
Supports personal Microsoft accounts (common tenant) and Azure AD tenants.
import { MicrosoftStrategy } from '@agentine/aegis';
aegis.use(new MicrosoftStrategy(
{
clientID: process.env.MICROSOFT_CLIENT_ID,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
callbackURL: '/auth/microsoft/callback',
tenant: 'common', // or your tenant ID for organization-only login
// scope defaults to ['openid', 'profile', 'email', 'User.Read']
},
async (accessToken, refreshToken, profile) => {
return User.findOrCreate({ microsoftId: profile.id });
},
));Generic OpenID Connect with automatic endpoint discovery via .well-known/openid-configuration. Works with any compliant provider (Okta, Auth0, Keycloak, etc.).
import { OIDCStrategy } from '@agentine/aegis';
aegis.use(new OIDCStrategy(
{
issuer: 'https://accounts.example.com',
clientID: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
callbackURL: '/auth/oidc/callback',
// scope defaults to ['openid', 'profile', 'email']
},
async (accessToken, refreshToken, profile) => {
return User.findOrCreate({ sub: profile.id });
},
));Endpoint discovery is cached after the first request. The id_token is validated per OIDC Core: signature (RSA256/384/512 via JWKS), issuer, audience, expiry, and nonce.
SP-initiated SSO with XML signature verification. Requires the IdP's signing certificate.
import { SAMLStrategy } from '@agentine/aegis';
aegis.use(new SAMLStrategy(
{
entryPoint: 'https://idp.example.com/saml2/sso',
issuer: 'https://myapp.com', // Your entity ID
callbackURL: 'https://myapp.com/auth/saml/callback',
cert: process.env.SAML_IDP_CERT, // IdP signing certificate (PEM or raw base64)
},
async (profile) => {
return User.findOrCreate({
samlId: profile.nameID,
email: profile.attributes['email'],
});
},
));
// Initiate SAML flow
app.get('/auth/saml', aegis.authenticate('saml'));
// Handle IdP POST-back
app.post('/auth/saml/callback',
express.urlencoded({ extended: false }),
aegis.authenticate('saml', { failureRedirect: '/login' }),
(req, res) => res.redirect('/'),
);SAML profile fields:
| Field | Description |
|---|---|
issuer |
IdP entity ID from the assertion |
nameID |
The user's NameID value |
nameIDFormat |
NameID format URI |
sessionIndex |
SAML session index (for SLO) |
attributes |
All AttributeStatement values as Record<string, string> |
Express is the default. No adapter import needed — just use aegis middleware directly:
import aegis from '@agentine/aegis';
import express from 'express';
import session from 'express-session';
const app = express();
app.use(express.json());
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
app.use(aegis.initialize());
app.use(aegis.session());import Fastify from 'fastify';
import fastifySession from '@fastify/session';
import aegis, { LocalStrategy } from '@agentine/aegis';
import { toFastifyHook } from '@agentine/aegis';
const fastify = Fastify();
await fastify.register(fastifySession, { secret: process.env.SESSION_SECRET });
aegis.use(new LocalStrategy(async (username, password) => { /* ... */ }));
aegis.serializeUser(async (user) => user.id);
aegis.deserializeUser(async (id) => User.findById(id));
fastify.addHook('preHandler', toFastifyHook(aegis.initialize()));
fastify.addHook('preHandler', toFastifyHook(aegis.session()));
fastify.post('/login', {
preHandler: toFastifyHook(
aegis.authenticate('local', { session: false }),
),
}, async (request, reply) => {
return { user: request.raw.user };
});import Koa from 'koa';
import session from 'koa-session';
import bodyParser from 'koa-bodyparser';
import aegis, { LocalStrategy } from '@agentine/aegis';
import { toKoaMiddleware } from '@agentine/aegis';
const app = new Koa();
app.keys = [process.env.SESSION_SECRET];
app.use(session({}, app));
app.use(bodyParser());
app.use(toKoaMiddleware(aegis.initialize()));
app.use(toKoaMiddleware(aegis.session()));
// The authenticated user is available on ctx.state.user
app.use(async (ctx) => {
if (ctx.path === '/login' && ctx.method === 'POST') {
await new Promise<void>((resolve, reject) => {
toKoaMiddleware(aegis.authenticate('local', { session: true }))(ctx as any, async () => {
resolve();
});
});
ctx.redirect('/');
}
});aegis is TypeScript-first. Use the User generic to get full type safety across your application:
import { Authenticator, LocalStrategy } from '@agentine/aegis';
interface AppUser {
id: string;
email: string;
role: 'admin' | 'user';
}
// Typed authenticator
const auth = new Authenticator<AppUser>();
auth.serializeUser(async (user: AppUser) => user.id);
auth.deserializeUser(async (id): Promise<AppUser | null> => db.users.findById(id));
auth.use(new LocalStrategy<AppUser>(async (username, password) => {
const user = await db.users.findOne({ email: username });
if (!user || !await bcrypt.compare(password, user.passwordHash)) return false;
return user; // typed as AppUser
}));
// req.user is typed as AppUser | undefined
app.get('/profile', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).json({ error: 'Not authenticated' });
res.json({ email: req.user.email, role: req.user.role }); // fully typed
});const userAuth = new Authenticator<User>();
const adminAuth = new Authenticator<Admin>();
// Each has its own strategy registry and session serialization
userAuth.use(new LocalStrategy<User>(verifyUser));
adminAuth.use(new LocalStrategy<Admin>(verifyAdmin));The main class. The default export is a pre-created instance (new Authenticator()).
Register a strategy. Strategies self-name via their name property; pass an explicit name to override.
Remove a registered strategy.
Returns middleware that augments req with login(), logout(), isAuthenticated(), and isUnauthenticated().
| Option | Default | Description |
|---|---|---|
userProperty |
'user' |
Property name on req where the user is stored |
Returns middleware that restores authentication from the session on each request.
| Option | Default | Description |
|---|---|---|
optional |
false |
Don't fail if no session is present |
Returns authentication middleware.
strategy can be a single name or an array of names (tried in order until one succeeds).
| Option | Type | Description |
|---|---|---|
session |
boolean |
Save user to session on success (default: true) |
optional |
boolean |
Pass through without error if authentication fails |
successRedirect |
string |
Redirect on success |
failureRedirect |
string |
Redirect on failure |
failureFlash |
string | boolean |
Flash failure message (requires connect-flash) |
successFlash |
string | boolean |
Flash success message (requires connect-flash) |
failureMessage |
string | boolean |
Store failure message in req.session.messages |
successMessage |
string | boolean |
Store success message in req.session.messages |
assignProperty |
string |
Store user on req[property] instead of establishing a session |
Custom callback:
app.post('/login', (req, res, next) => {
aegis.authenticate('local', (err, user, info) => {
if (err) return next(err);
if (!user) return res.status(401).json({ error: info?.message });
req.login(user, (err) => {
if (err) return next(err);
res.json({ user });
});
})(req, res, next);
});Links an additional account to an existing session without replacing req.user. The linked account is stored on req.account (or req[options.assignProperty]).
Register session serialization/deserialization. Both async and callback styles are supported:
// Async
aegis.serializeUser(async (user) => user.id);
aegis.deserializeUser(async (id) => User.findById(id));
// Callback (passport-compatible)
aegis.serializeUser((user, done) => done(null, user.id));
aegis.deserializeUser((id, done) => User.findById(id, done));| Method | Description |
|---|---|
req.isAuthenticated() |
Returns true if a user is authenticated |
req.isUnauthenticated() |
Returns true if no user is authenticated |
req.login(user, done) |
Log in a user (establishes session) |
req.logout(done) |
Log out the current user (clears session) |
req.user |
The authenticated user object |
Thrown when authentication fails and no redirect is configured:
import { AuthenticationError } from '@agentine/aegis';
// In Express error handler:
app.use((err, req, res, next) => {
if (err instanceof AuthenticationError) {
return res.status(err.status).json({ error: err.message });
}
next(err);
});PKCE is enabled by default for all OAuth 2.0 flows. It prevents authorization code interception attacks even when TLS is terminated early. Do not disable it unless your provider explicitly does not support it.
// PKCE is on by default — no configuration needed
new GoogleStrategy({ clientID, clientSecret, callbackURL }, verify);
// Explicit disable (not recommended)
new OAuth2Strategy({ /* ... */, pkce: false }, verify);The state parameter is enforced by default on all OAuth 2.0 flows. aegis generates a cryptographically random 48-hex-char state, stores it in the session, and validates it on callback. Mismatches return HTTP 403.
aegis regenerates the session ID on every successful login to prevent session fixation attacks. This requires express-session (or compatible) to expose req.session.regenerate().
The authenticate() middleware validates all redirect URLs before issuing redirects:
- Relative paths (
/path) are allowed https://URLs are allowed (for OAuth provider redirects)http://localhostURLs are allowed (for development)- Protocol-relative URLs (
//evil.com) are rejected with HTTP 400 - Non-https schemes (
javascript:,data:, etc.) are rejected
Configure express-session with appropriate cookie settings for production:
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Prevent XSS access to the cookie
secure: true, // Require HTTPS (set this in production)
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}));- The
certoption is required. aegis will throw at construction time if it is missing. - Signature verification uses the provided certificate for both the response-level and assertion-level signatures.
InResponseToattribute is validated to prevent unsolicited response injection.Destinationattribute is validated to prevent response re-use across service providers.- Assertion conditions (
NotBefore,NotOnOrAfter,AudienceRestriction) are validated with 5-minute clock skew tolerance.
Run against Node.js 22 on Apple M-series:
initialize() middleware:
aegis initialize(): 1,086,818 ops/sec (0.92 µs/op)
authenticate("local") — success:
aegis local auth (success): 685,556 ops/sec (1.46 µs/op)
authenticate("local") — failure:
aegis local auth (failure): 487,755 ops/sec (2.05 µs/op)
Run the benchmarks yourself:
npm run benchMIT