Next.js authentication after sign-in: who handles the rest?
The question
Every comparison of Next.js authentication libraries answers the same question: how do I get a user signed in. That question has been settled for years and almost anything on the list will do it.
The question that decides what your next six months look like is the one after it. Who handles everything that follows sign-in?
Because sign-in is not the feature. The feature is: every API request carries a credential that means something, a user who asks to leave actually leaves, and someone who is signed in still cannot do what they are not allowed to do.
SPFN is ours — we build it, and @spfn/auth is one of the four answers below. The
other three are compared on their own terms, and one of them is better than ours for most
readers.
Four answers
| Answer | What you pay | What you are left holding |
|---|---|---|
| Build it yourself | design, argument, decision, tests, measurement | all of it, forever |
| Have an agent write it | tokens, and review time | the same decisions, now unasked |
| Wire a library | learning and integration | whatever the library does not cover |
| Install a slice | nothing, then move on | a narrower set of choices |
The second row is the one this page exists to talk about, because it is new and because it is where the reasoning usually goes wrong.
What "after sign-in" actually contains
Three things, and none of them is exotic.
A credential on every request. Something travels with each API call and the server decides whether to trust it. That is a design decision with consequences, not a checkbox.
Account deletion. Apple and Google both require an app that lets people create an account to let them delete it from inside the app. Accepting the request and never processing it is the common failure, and it is worse than not shipping the button — now there is a promise on the screen and a row in a table that nothing consumes.
Authorization. Sign-in answers who someone is. It says nothing about what they may do, and a product runs on the second answer.
Answer one: build it yourself
This is the honest baseline and sometimes the right call. You will make a series of decisions, each small and each with a wrong version:
- Does a stolen session credential let the thief act as the user, and for how long?
- When someone signs in on a second device, does the first one get signed out?
- What happens to a deletion request while the grace period runs — can they still log in?
- Does a purge that runs while the user is cancelling destroy the account anyway?
- Does the audit record of the deletion survive the deletion?
None of these is hard once stated. Stating them is the work.
Answer two: have an agent write it
This is now the default, and it deserves a straight answer rather than a sneer.
An agent will write you a deletion endpoint. It will be a good deletion endpoint. It will match what you asked for.
It will not tell you that the app stores require the button, that a grace period is what turns a deletion into a recoverable mistake, that sessions have to be revoked at request time rather than at purge time, that a concurrent cancel can race the sweep, or that the audit row should outlive the user row it points at. An agent answers the question you asked. The expensive part was knowing which question to ask.
The cost here is real but it is not the token bill. It is that you now own a system whose design decisions were made implicitly, by whoever phrased the prompt, and you will discover which ones were wrong in production.
Answer three: wire a library — Better Auth
Better Auth describes itself as a framework-agnostic, universal authentication and authorization framework for TypeScript, and it is the strongest general answer available. Email and password, sessions, rate limiting, automatic database handling and migrations, social providers, organisations and access control, two-factor auth, and a plugin ecosystem covering passkeys, multi-tenancy, multi-session and enterprise SSO.
If you are choosing an auth library for a Next.js app today, this is the one to choose, and the rest of this page does not change that.
Two places where it hands work back, both from its own documentation.
Non-browser clients get a bearer token. The Bearer plugin puts the session token in an
Authorization header, and its documentation attaches an explicit caution that improper
implementation can easily lead to vulnerabilities. Request signing, per-device keys, nonce
and replay protection are not part of it. In a browser this is a non-issue — the default
is an httpOnly cookie and that design is fine. It matters on the API path, which is where
a product ends up.
Deletion is a hard delete. It is off by default, and when enabled it removes the user
immediately. There are verification options and beforeDelete / afterDelete hooks to
hang cleanup on. What is not there is the part that takes time to get right: a grace
period, a recovery path, what a pending account is allowed to do, and a sweep that cannot
be raced.
Answer four: install a slice — @spfn/auth
@spfn/auth is not a library you wire. It is a vertical slice you mount, arriving with
its routes, its typed client, its tables and its migrations:
export const appRouter = defineRouter({ /* your routes */ })
.packages([authRouter]) // mounts /_auth/* and exposes them on the typed authApi
.use([authenticate]); // auth applies globally; routes opt out per-route
The credential is asymmetric, so the server holds nothing worth stealing
The client generates an ES256/RS256 keypair, sends only the public half at register or login, and signs each request itself. The server verifies against the stored public key and holds no private key for any user.
The consequence is one sentence: a stolen bearer token is usable by whoever stole it, and a stolen public key is not.
Keys are per-device and accumulate on purpose, so signing in on a new phone does not sign
out the laptop. They expire after 90 days and rotateKey renews one. listKeys shows the
owner what accumulated, revokeKey cuts one off, revokeAllKeys cuts off everything
else. Expiry is computed at request time rather than stored, because a list that showed an
expired key as active would report something the server does not act on. Sessions default
to seven days.
Deletion is a state machine, not a DELETE
active ──request (re-auth)──> pending_deletion ──grace elapses──> anonymised or removed
^ │
└───────────cancel (re-auth)───────┘
- Request requires step-up re-auth — a password, or a verification code for OAuth-only accounts. Every active session key is revoked at that moment, an audit row is written, and the user is told the scheduled purge date.
- Login is refused while pending, with a dedicated error carrying the purge date, so the client can offer recovery instead of showing a generic failure.
- Cancel is public, because the sessions were already revoked and there is no token left to authenticate with. Credentials plus a password or a fresh code restore the account.
- The sweep runs daily once registered with
.jobs(authJobRouter), and it is deliberately not automatic — a purge job that appears without you asking is worse than one you had to type. - Purge strategy is a choice:
anonymizescrubs the PII and keeps the row, orhard-deleteremoves it and cascades the children. - The audit row outlives the user row. Its foreign key is
set null, not cascade, so who requested what and when survives the deletion. - A concurrent cancel cannot be raced. The sweep re-verifies the account is still pending on the write primary, inside the same transaction as the destructive statement, and claims the request with a conditional update that matches zero rows if a cancel got there first.
- Admin and GDPR entry points are exported as services; your app owns the route and its authorization.
Two things this does not do, stated because you would find out anyway: a purged account's email becomes immediately reusable, and data-subject-request intake and response workflows are yours.
Authorization comes with it
Roles and permissions, a global authenticate middleware that individual routes opt out
of, and hasPermission and getUserRole exported so the app can gate its own surfaces
with the same source of truth.
Where Better Auth wins
- It works anywhere.
@spfn/authonly runs inside an SPFN app. For most readers the comparison ends on that line, and it should. - It is the recommended default for new self-hosted Next.js projects, and it earned that position.
- Its plugin range is wider today — passkeys, multi-tenancy, enterprise SSO, multi-session.
- Its ecosystem is larger in every measurable way, which means more answers already written to the question you are about to have.
The summary
| Build it | Agent writes it | Better Auth | @spfn/auth | |
|---|---|---|---|---|
| Sign-in | you | you review | yes | yes |
| Non-browser credential | you decide | you decide, silently | bearer token | client-signed, per-device |
| Server holds a usable secret | your choice | your choice | session token | no |
| Deletion | you build | you build | immediate hard delete | grace, recovery, sweep |
| Authorization | you build | you build | yes | yes |
| Runs outside its framework | yes | yes | yes | no |
If the app is not an SPFN app, use Better Auth. If it is, the argument for @spfn/auth is
not that it does something no one else can. It is that the decisions in the middle column
have already been made, argued, tested and measured, and you get to skip to the next
thing.
- Full-stack TypeScript frameworks compared — the framework-level version of this question
- @spfn/auth documentation