Email/password Auth
This pack adds email and password authentication to your app. Users sign up, log in, and log out, and every new account confirms its email address before it is treated as active.
- Email verification for new signups, with a screen to resend the confirmation mail.
- Rate-limited login and signup that penalise repeated failed attempts.
- A branded, MJML-based confirmation email.
Apply the authentication pack with Flow. Start a new session in your selected coding agent and execute the following slash command inside it.
After applying, Flow will make the following changes to your app:
- Establish the foundation it needs, a session
webguard, theguestandauthmiddleware, and an authenticated landing route, wherever your app is missing one. - Land the backend as shipped, from the migrations and model mixin through the validators, controllers, routes, middleware, and mailer.
- Configure mail and the limiter to the choices you give it.
- Restyle the login, signup, activation, and verify-email screens to your design system.
- Run the tests, then walk the signup, verify, and login flow and show you the result.
Apply the authentication pack to your app by hand by working through the steps below in order. Each one builds on the last, taking your app from no auth to a working signup, login, logout, and email-verification flow.
-
Confirm your auth foundation
This pack builds on three things your app should already have. Set up any it lacks before continuing.
- A session
webauth guard backed by your User model. Login and logout useauth.use('web'). - The
guestandauthnamed middleware. The routes gate on them. - An authenticated landing route. Login redirects here, called
dashboardin the steps below.
- A session
Flow adapts the pack to your app, so the exact set of created and edited files depends on what you already have. This is the shape of an apply onto the Hypermedia starter kit.
Configuration
Verification is backed by the withManagedEmail() mixin on the User model, which stores each token in a database table. Pass an options object to the mixin to change its defaults.
expiresIn
How long a verification token stays valid, as a number of seconds or a time expression. Defaults to 1 day.
table
The table verification tokens are read from and written to. Defaults to email_verification_tokens.
tokenSecretLength
The length of the random secret behind each token. Defaults to 40.
export default class User extends compose(
UserSchema,
withAuthFinder(hash),
withManagedEmail({ expiresIn: '2 hours' })
) {}
Verification is two email columns, not a boolean
The mixin tracks verification with two columns:
emailis the confirmed address.unverifiedEmailis one still waiting on confirmation.
Signup writes the same value to both, so the two agree and the account reads as unverified. Confirming the address moves it into email and clears unverifiedEmail, so they diverge and the account reads as active. hasInactiveAccount exposes this state, and VerifiedAccountMiddleware gates on it.
Verification tokens
Tokens live in the email_verification_tokens table, one row per outstanding token, linked to the user by tokenable_id. The mixin generates a token when you ask for one and verifies it when the link comes back, storing only its hash so a leaked row cannot be turned back into a working link.
Resend throttling
Resending is throttled to one token a minute. Inside that window the endpoint does nothing and answers exactly as it would have, so the timing stays invisible to callers.
Rate-limited credentials
Login runs the credential check through two limiter keys:
- one scoped per IP, and
- a stricter one per IP-and-email that adds a 20-minute block after five failures, which slows a targeted guess at a single account.
Both run through penalize, which spends the budget only when credentials fail, so a valid login is always free.
const ipKey = `login_${request.ip()}`
const emailKey = `login_${request.ip()}_${payload.email}`
const loginLimiter = limiter.multi([
{ duration: '1 min', requests: 10, key: ipKey },
{ duration: '1 min', requests: 5, blockDuration: '20 mins', key: emailKey },
])
const [error, user] = await loginLimiter.penalize(() => {
return User.verifyCredentials(payload.email, payload.password)
})
Signup takes a simpler guard, formsThrottle, a named limiter capped at ten requests a minute per route and IP.
export const formsThrottle = limiter.define('forms', (ctx) => {
return limiter
.allowRequests(10)
.every('1 minute')
.usingKey(`${ctx.route?.name ?? 'global'}_${ctx.request.ip()}`)
})
1.0.0
Initial release.