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.
Acme Project
sash_live_7f3a9c…
jane@acme.dev
verified · active
marcus@acme.dev
session active
amir@acme.dev
unverified
Pick a piece. See exactly how it works, straight from the source.
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.
model User {email StringprojectId String@@unique([email, projectId])}
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.
ProjectOwner login
NextAuth Credentials provider, lib/auth.ts
End-user auth
/api/v1/*, called from @septic/sdk
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/*.
Every /api/v1/* request, same order
Branches off, never awaited. A dead endpoint, 10s timeout, can't slow your users down.
Postgres, via Prisma
Durable data: ProjectOwner, Project, User
owner@acme.dev
Acme Project
jane@example.com
Beta Project
jane@example.com
@@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 pattern | TTL | Description |
|---|---|---|
| session:<sessionId> | 7 days, sliding | Maps a session to { userId, projectId }. TTL refreshes on every GET /api/v1/me call. |
| otp:<verify|reset>:<projectId>:<email> | 10 minutes | The 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 minutes | Wrong guess counter. Both keys get deleted together after the 5th failed attempt. |
| rate_limit:<projectId>:<ip> | 60 seconds | The 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.
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.
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.
Creates an end user account scoped to your project. Returns the user and a sessionId, sets the session cookie right away.
API keyAuthenticates an end user. Wrong password or unknown email both get the same "Invalid credentials." No enumeration.
API keyDestroys the session in Redis and clears the cookie. Works even if the session already expired.
SessionFetches the current end user and slides the session's 7 day TTL forward. What SashProvider calls on mount to restore state.
SessionStarts a password reset. Returns the same message whether or not the email exists.
API keyVerifies the 6 digit code and sets a new password. Invalidates every other session that user had open.
API keySends a 6 digit email verification code. Same anti-enumeration guarantee as forgot-password.
API keyVerifies the code and sets emailVerified to true on the user record. Fires user.email_verified.
API keycurl -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!"}'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"));
}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…