Account security flows
These flows are opt-in via useAuth options. They build on the same adapter and add routes under the auth router's mount path (e.g. /api/auth).
CSRF protection
useAuth({ adapter, login, csrf: true });
// or: csrf: { headerName: "X-CSRF-Token", cookieName: "csrf_token", skip: (c) => false }
Uses the double-submit-cookie pattern: a non-httpOnly csrf_token cookie is issued on safe requests and refreshed on login, and unsafe methods (POST/PUT/PATCH/DELETE) must echo it back in the X-CSRF-Token header. Requests carrying an Authorization header (bearer/API-key clients) are exempt. A mismatch returns 403. Also available standalone as createCsrfMiddleware.
The Covara client handles this for you. The client transport (client.session.* and every other unsafe request) and the React useAuth() hook (login/signup/logout/verification) read the csrf_token cookie and echo it in X-CSRF-Token. If no token cookie has been issued yet, they first make a GET /me to get one. The CSRF check covers /login itself, so this priming step is what lets a first login succeed. If you renamed the cookie or header on the server, pass the same names to the client:
getOrCreateClient({ baseUrl: location.origin, csrf: { cookieName: "xsrf", headerName: "X-XSRF" } });
useAuth({ csrf: { cookieName: "xsrf", headerName: "X-XSRF" } }); // React hook
// csrf: false disables sending the header
The token is read from document.cookie, so it is only sent from a browser on the same site as the API. A cross-site client should authenticate with a bearer token, which is exempt.
Login throttling
useAuth({ adapter, login, throttle: true });
// or: throttle: { maxAttempts: 5, windowMs: 15 * 60 * 1000, store: myRateLimitStore }
Failed logins are counted per email and per IP; once maxAttempts (default 5) is exceeded within the window (default 15 min), /login returns 429 with Retry-After. A successful login resets the counters. Provide a distributed RateLimitStore (e.g. Redis-backed) for multi-instance deployments; the default is in-memory.
Email verification
import { InMemoryVerificationTokenStore } from "covara";
useAuth({
adapter,
login,
verification: {
store: new InMemoryVerificationTokenStore(),
sendToken: async ({ identifier, token, expiresAt }) => sendEmail(identifier, token),
markVerified: async (email) => db.update(users).set({ emailVerified: true }).where(eq(users.email, email)),
ttlMs: 24 * 60 * 60 * 1000, // default 24h
hashTokens: true, // store SHA-256 of the token
},
});
Adds POST /verify/request (issues a one-time token and calls sendToken) and POST /verify/confirm ({ email, token } → calls markVerified).
Password reset
useAuth({
adapter,
login,
passwordReset: {
store: new InMemoryVerificationTokenStore(),
sendToken: async ({ identifier, token }) => sendEmail(identifier, token),
resetPassword: async (email, passwordHash) =>
db.update(users).set({ passwordHash }).where(eq(users.email, email)),
findUserByEmail: async (email) => db.query.users.findFirst({ where: eq(users.email, email) }),
ttlMs: 60 * 60 * 1000, // default 1h
logoutEverywhere: true, // revoke all sessions after reset
},
});
Adds:
POST /password/forgot—{ email }→ issues a token viasendToken(orcomposeEmail); always returns{ success: true }(no email enumeration). A delivery failure is reported toemail.onDeliveryError(default: aconsole.errorwithout the address or token) and never changes the response, so a provider outage can't reveal which addresses have accounts.POST /password/reset—{ email, token, password }→ hashes the new password with the built-in scrypt hasher and callsresetPassword. WhenlogoutEverywhereis set and the adapter implementsinvalidateUserSessions, all of the user's sessions are revoked.
Sending the emails
Each flow takes either a callback (sendToken / sendLink) that sends the email yourself, or composeEmail, which returns an EmailMessage that Covara sends through email.adapter (default: the global email adapter). The composed path passes an idempotency key derived from the token's SHA-256 (auth:<flow>:<hex>), so a retried send never delivers the same token twice.
import { createEmail, durableEmail, createResendAdapter } from "covara/email";
useAuth({
adapter,
email: {
adapter: durableEmail(createResendAdapter({ apiKey: env.RESEND_API_KEY }), { queue: scheduler }),
onDeliveryError: (error, { flow }) => metrics.increment(`auth.email.${flow}.failed`),
},
passwordReset: {
store,
resetPassword,
findUserByEmail,
composeEmail: ({ identifier, token }) => {
const { html, text } = createEmail()
.heading("Reset your password")
.button("Reset password", `https://app.com/reset?token=${token}`)
.build();
return { from: "noreply@app.com", to: identifier, subject: "Reset your password", html, text };
},
},
});
/password/forgot and /magic-link/request swallow delivery errors (they must answer identically for known and unknown addresses); /verify/request still surfaces them as a 500. With a durable email adapter the request only enqueues the message, so a slow or failing provider affects neither the response nor its latency. useAuth throws at setup if a flow has neither a callback nor composeEmail.
Token stores
verification, passwordReset, and magicLink all use the VerificationTokenStore interface (create/consume/deleteByIdentifier). InMemoryVerificationTokenStore ships for development; back it with your own table for production. Set hashTokens: true to store only the SHA-256 of each token.