@zephkelly/nuxt-authkit
v0.0.2
Published
An authentication toolkit for Nuxt applications
Readme
@zephkelly/nuxt-authkit
An authentication toolkit for Nuxt applications. JWT access/refresh tokens with rotating refresh tokens, HttpOnly cookie storage, role guards, and scrypt password hashing.
Upgrading from 0.0.x? Read MIGRATION.md first. This release logs every user out once — tokens minted by 0.0.x carry no
typclaim and are now rejected — and three changes need action in consuming apps.
Setup
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@zephkelly/nuxt-authkit'],
nuxtAuthkit: {
strategies: ['jwt'],
strategy: {
name: 'jwt',
tokens: {
access: { expiresIn: 900 }, // 15 minutes
refresh: {
expiresIn: 604800, // 7 days
rotate: true, // rotate on every refresh (default)
cookie: {
name: 'refreshToken',
secure: true,
httpOnly: true, // forced on regardless
sameSite: 'strict'
}
}
},
jwt: {
algorithm: 'RS256',
privateKey: process.env.JWT_PRIVATE_KEY!,
publicKey: process.env.JWT_PUBLIC_KEY!
}
}
}
});Generate an RSA key pair:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out private.pem
openssl rsa -pubout -in private.pem -out public.pemKeep the private key in JWT_PRIVATE_KEY (or NUXT_NUXTAUTHKIT_STRATEGY_JWT_PRIVATE_KEY)
— it belongs in the environment, never in a committed config or a published build
output.
Wiring it up
Register the strategy in a Nitro plugin. The callbacks are where your app plugs in its own storage and user directory.
// server/plugins/auth.ts
export default defineNitroPlugin(() => {
createNuxtAuthkit(
createJWTStrategy({
// Required: verify credentials.
async onAuthenticate({ email, password }) {
const user = await db.users.findByEmail(email);
if (!user || !await verifyPassword(user.passwordHash, password)) {
return null;
}
return { id: user.id, roles: user.roles };
},
// Required: persist a refresh token. Store a HASH, not the token —
// then a leaked database snapshot is not a set of live sessions.
async onStoreRefreshToken(token, userId) {
await db.refreshTokens.insert({ userId, hash: sha256(token) });
},
// Required: look one up.
async onGetRefreshToken(token, userId) {
const record = await db.refreshTokens.find(sha256(token));
if (!record) return { valid: false };
// Rotated away already but presented again → the token was stolen.
// authkit responds by revoking the whole family via onRevokeTokenFamily.
// Allow a short grace period, or concurrent refreshes log users out.
if (record.consumedAt) {
const age = Date.now() - record.consumedAt;
return age < 10_000 ? { valid: true } : { valid: false, reused: true };
}
// Roles read fresh from the DB, so a ban applies on the next refresh
// rather than whenever the user next logs in.
return { valid: true, roles: await db.users.rolesOf(userId) };
},
// Strongly recommended: without this, logout cannot terminate a session
// server-side and rotation never invalidates the old token.
async onRemoveRefreshToken(token) {
await db.refreshTokens.markConsumed(sha256(token));
},
// Recommended: theft response.
async onRevokeTokenFamily(family) {
await db.refreshTokens.revokeFamily(family);
},
// Recommended: admin force-logout. revokeServer() throws without it.
async onRevokeServer({ userId }) {
await db.refreshTokens.deleteAllForUser(userId);
}
})
);
});The library warns at startup about any recommended callback you have not implemented, and what the consequence is.
Protecting routes
export default defineEventHandler({
onRequest: [
() => hasAuthkitSession(), // 401 without a valid session
() => hasAuthkitRole('admin'), // 401 unauthenticated, 403 without the role
() => rejectAuthkitRole('banned') // deny-filter; also 401 unauthenticated
],
handler: async () => { /* ... */ }
});rejectAuthkitRole is a deny-filter, not an authentication gate. It requires a
valid session: proving a caller does not hold a role first requires knowing who
they are.
Tokens
authenticate() sets the refresh token as an HttpOnly cookie and returns the
access token. Never put the refresh token in a response body — from there it
reaches JavaScript, and it is a multi-day credential.
Access and refresh tokens carry a signed typ claim, so a refresh token cannot be
replayed as a Bearer access token.
On the client, the shipped plugin attaches the access token to same-origin requests and transparently refreshes + retries once on a 401:
const { accessToken, setAccessToken, clearAccessToken } = useAuthkitToken();Passwords
const hash = await hashPassword(plain);
const ok = await verifyPassword(hash, plain);scrypt via @adonisjs/hash. Cost parameters are configurable under
nuxtAuthkit.scrypt; parameters are embedded in each stored hash, so raising them
later does not invalidate existing passwords.
Security
See SECURITY-AUDIT-JWT.md for the audit this library's current design responds to, and MIGRATION.md for the resulting changes.
