Auth-as-a-Service · v0.1 · Phase 5

Auth for your personal projects.

Sash is a drop-in alternative to Clerk or Auth0. Sessions live in Redis, passwords are hashed with bcrypt, every user is scoped to a project by a database constraint, not a shared table and a filter. It's small enough to read end to end in an afternoon.

What's inside

Everything a real auth system needs

Pick a piece. See exactly how it works, straight from the source.

01 / 08

Real multi-tenancy

User is unique on [email, projectId]. The same jane@example.com can be a fully independent row in two different projects: different password, different verification state, different sessions. No query ever crosses that line.

prisma/schema.prisma
model User {
email String
projectId String
@@unique([email, projectId])
}
Architecture

One system, two ways in, no shared tables

A developer signs into the Sash dashboard through NextAuth. Their app's end users sign into that app through the public API, a completely separate session system. Both paths land on the same Postgres database, but only end-user traffic touches Redis, and every row it writes is scoped to a single project.

System 1 · Dashboard

ProjectOwner login

NextAuth Credentials provider, lib/auth.ts

System 2 · Public API

End-user auth

/api/v1/*, called from @septic/sdk

bcrypt.compare()

authorize() looks up the ProjectOwner by email and checks the password hash. No API key, no Redis. NextAuth signs a JWT session cookie and the request never touches /api/v1/*.

ProjectOwner
bcrypt hash
JWT cookie

Every /api/v1/* request, same order

validateApiKeyBearer sash_live_… → Project
rateLimit10 attempts / 60s on signup + login
Zod safeParsereq.json() validated against a schema
Prismaproject-scoped read/write
jsonSuccess / jsonErrorconsistent response shape
dispatchWebhook()

Branches off, never awaited. A dead endpoint, 10s timeout, can't slow your users down.

Postgres, via Prisma

Durable data: ProjectOwner, Project, User

ProjectOwner

owner@acme.dev

Project

Acme Project

jane@example.com

verifiedactive
Project

Beta Project

jane@example.com

unverifiedsuspended

@@unique([email, projectId])

The multi-tenancy keystone, straight from prisma/schema.prisma. Two rows, same email, zero relationship. Every query is scoped by projectId.

Redis, TTL-driven

Ephemeral state: sessions, OTPs, rate limits

Key patternTTLDescription
session:<sessionId>7 days, slidingMaps a session to { userId, projectId }. TTL refreshes on every GET /api/v1/me call.
otp:<verify|reset>:<projectId>:<email>10 minutesThe 6 digit code, namespaced per project and email so the same address in two projects gets independent codes.
otp_attempts:<verify|reset>:<projectId>:<email>10 minutesWrong guess counter. Both keys get deleted together after the 5th failed attempt.
rate_limit:<projectId>:<ip>60 secondsThe INCR counter behind the 10 request cap on signup and login, keyed per project.

Bulk invalidation (e.g. after a password reset) walks these keys with Redis SCAN, never KEYS. Upstash blocks KEYS outright in production.

How it's actually built

Four bugs we hit, and how the code answers them

No hand waving. Every line below points at the real file that solves it. Tap one open to read the detail.

Redis silently turns your OTP into a number.

Every comparison gets coerced back to a string first.

A password reset endpoint that confirms an email exists is a leak.

forgot-password, send-verification, and login all answer the same way.

Invalidating a user's sessions shouldn't mean scanning production Redis with KEYS.

invalidateAllUserSessions() walks the keyspace with SCAN instead.

A dead webhook endpoint shouldn't be able to slow down a login.

Webhook delivery is fire and forget, on a 10 second timeout, never awaited.

Public API

Eight endpoints. One API key.

Everything under /api/v1/* is scoped by the sash_live_ API key that identifies your project, except /logout and /me, which run off the end user's own session instead.

api/v1 route table
  • POST/api/v1/signup

    Creates an end user account scoped to your project. Returns the user and a sessionId, sets the session cookie right away.

    API key
  • POST/api/v1/login

    Authenticates an end user. Wrong password or unknown email both get the same "Invalid credentials." No enumeration.

    API key
  • POST/api/v1/logout

    Destroys the session in Redis and clears the cookie. Works even if the session already expired.

    Session
  • GET/api/v1/me

    Fetches the current end user and slides the session's 7 day TTL forward. What SashProvider calls on mount to restore state.

    Session
  • POST/api/v1/forgot-password

    Starts a password reset. Returns the same message whether or not the email exists.

    API key
  • POST/api/v1/reset-password

    Verifies the 6 digit code and sets a new password. Invalidates every other session that user had open.

    API key
  • POST/api/v1/send-verification

    Sends a 6 digit email verification code. Same anti-enumeration guarantee as forgot-password.

    API key
  • POST/api/v1/verify-email

    Verifies the code and sets emailVerified to true on the user record. Fires user.email_verified.

    API key
Try it
curl -X POST https://your-app.com/api/v1/signup \
  -H "Authorization: Bearer sash_live_4f8a2e9d7c1b3a6f..." \
  -H "Content-Type: application/json" \
  -d '{"email":"jane@example.com","password":"Str0ngPassw0rd!"}'
Verify a webhook

Every webhook POST carries an X-Sash-Signature: sha256=… header, an HMAC-SHA256 digest of the raw body signed with your WEBHOOK_SIGNING_SECRET.

import crypto from "crypto";

function verifySashWebhook(rawBody: string, signature: string, secret: string): boolean {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}
Live sandbox

Try it for real.

This is the actual SDK talking to the actual API. Sign up, verify with a real one time code, sign back in. Nothing here is mocked.

Your signup creates a real row in the same Postgres database and Redis session store every Sash project runs on. It's just sandboxed to a demo project, isolated from every real customer by the same @@unique([email, projectId]) constraint that protects them.

Restoring session…