Email/password done in an afternoon

Argon2id, a sessions table, a rate limiter, and a reset flow. Ship it by dinner if you stop second-guessing.

· LoginWith team

People treat email/password authentication as if it’s a weeks-long project. It’s not. The core implementation is a handful of endpoints and a table. The reason it takes longer in practice is edge-case paralysis — which we’ll address.

The schema

Two tables, that’s it:

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  email_verified_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE sessions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON sessions (user_id, expires_at);

That’s your whole schema for sign-up, sign-in, and sessions. Add a password_resets table when you get to reset (same shape, with a token and used flag).

The endpoints

POST /sign-up

1. Validate email format.
2. Check if user exists (return generic error to prevent enumeration).
3. Hash the password with Argon2id.
4. Insert into users.
5. Send email verification link.
6. Create session.
7. Set HttpOnly Secure SameSite=Lax cookie.
8. Return success.

POST /sign-in

1. Look up user by email.
2. Verify password with Argon2id.
3. If invalid, return generic error, log failure, increment per-account counter.
4. If valid, rotate session ID (don't reuse any pre-signin session).
5. Create session, set cookie.
6. Return success.

POST /sign-out

1. Delete session from DB.
2. Clear cookie.

POST /forgot-password

1. Look up user by email (or not — respond identically either way).
2. Generate one-time token, store with 15-min expiry.
3. Email token as a link.
4. Return success regardless.

POST /reset-password

1. Validate token: exists, not expired, not used.
2. Hash new password.
3. Update users.password_hash.
4. Mark token as used.
5. Invalidate all existing sessions for the user.
6. Return success.

The middleware

1. Read session_id from cookie.
2. Look up session in DB.
3. If valid, attach req.user.
4. If invalid, clear cookie.

Fewer than 50 lines total.

Rate limiting

One middleware in front of sign-in, forgot-password, and reset-password:

  • Per account: 5 attempts in 5 minutes → 15-minute lockout
  • Per IP: 100 attempts per minute → short block

Redis for the counters, or Postgres if you’re not running Redis yet.

Why it takes “weeks” in practice

The code is an afternoon. The time-sink is:

  • Debating password requirements (just follow NIST SP 800-63B)
  • Debating session lifetime (24 hours sliding, fight me)
  • Debating “remember me” UX (default it on)
  • Testing edge cases (write one integration test per endpoint)
  • Email deliverability for verification and reset (use a managed sender)

Pick defaults, ship it, iterate. One afternoon.

Want auth that just works?

Get started with LoginWith