Email/password Auth
This pack adds email and password authentication to your API as JSON endpoints. It fits the guard your app already authenticates with, access tokens or session, enhancing your existing login and signup rather than replacing them. Every new account confirms its email address before it is treated as active.
- Email verification for new signups, with a resend endpoint and a
403on unverified access. - Rate-limited login and signup that penalise repeated failed attempts.
- A branded, MJML-based confirmation email that links to your frontend.
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:
- Discover your default guard, access tokens or session, and enhance the login and signup controllers you already have.
- Land the backend as shipped, from the migrations and model mixin through the validators, the email-verifications controller, routes, middleware, and mailer.
- Configure mail and the limiter, install Edge for the email templates, and point the verification link at your
FRONTEND_URL. - Run the tests, drive the endpoints, and hand you a brief for the frontend to build against.
Apply the authentication pack to your API by hand by working through the steps below in order. Each one builds on the last, taking your API from no auth to a working signup, login, logout, and email-verification flow.
-
Confirm your setup
This pack fits your app's existing auth. Confirm three things before continuing.
- Your default guard. In
config/auth.ts, read thedefaultguard and its definition. AsessionGuardmeans you follow the Session guard blocks below, atokensGuardmeans the Token guard blocks. The API starter defaults to thewebsession guard. - A
serializehelper and aUserTransformer. Controllers return their payloads throughserialize()andUserTransformer.transform(), landing the body under a top-leveldatakey. The API starter ships both. - The
authnamed middleware. The authenticated routes gate on it.
- Your default guard. In
Flow adapts the pack to your app, so the exact set of created and edited files depends on what you already have, including which guard you authenticate with. This is the shape of an apply onto the API 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.