In the boilerplate I use to start projects, the commit that integrates the first auth provider and the one that replaces it with the second are two days apart. Two days. The first added middleware for protected routes and a login screen; the second ripped it all out and dropped in another SDK with its own sign-in and sign-up pages. Since then that same template has been through two more providers and ended up, almost a year later, with hand-written middleware.
It isn't bad luck. It's a pattern, and I've repeated it in at least four repos.
I always start with a managed provider, and always for the same reason
On day one of a project I don't want to write password hashing, email verification, or the OAuth flow with Google. I install an SDK, set two environment variables, and there's login by the end of the afternoon. It's the right call that day: the project doesn't yet know whether it will exist in a month.
The problem is that the SDK doesn't stay put at the edge of the application. It gets into the layout, into the user context, into every server action that needs to know who's calling, and into the database schema, because the user_id from its table ends up as the foreign key in all of yours.
The four migrations and what triggered each one
- Provider one to provider two, in two days. The first one worked, but the pricing model and the coupling to its own dashboard didn't fit. Not a single line of auth survived the change.
- Provider two, six months together. This one had everything: README notes about its odd behavior with Suspense, middleware refactors to read the protected domains from a config file, and a switch from
useProfiletouseUserto stop maintaining my own layer on top of theirs. - Provider two to provider three. A whole PR named "migrate from" the previous one's name. Thirty files touched.
stack.tsand the catch-all routeapp/handler/[...stack]/page.tsxget deleted, andauth.tson the client plusapp/api/auth/[...path]/route.tson the server show up. - Provider three to my own middleware. The commit literally says "replace neonAuth with custom auth middleware and update session handling". From the managed SDK I keep the client; the rest I control.
What always breaks: the URLs
In the second migration there are two commits on the same day, one right after the other. The first leaves the config file like this:
export const stackServerApp = new StackServerApp({
tokenStore: "nextjs-cookie",
urls: {
signIn: "/ass",
signUp: "/asdds",
},
});
That's what comes out when you're testing by hand where each thing redirects and you leave the junk typing in. The second commit puts both back to /auth. It's ridiculous and it's exactly the failure you're going to eat in every auth migration: the provider doesn't know where your login screen lives, so there's a URL declared in its config that has to match a real router route, and no types check it. It redirects to a 404 and the symptom isn't "wrong URL": it's "login doesn't work".
The other half of what breaks is the shape of the session. In the move to my own middleware, getSession went from returning the SDK's object to returning something explicit:
type SessionData = NonNullable<Awaited<ReturnType<typeof auth.getSession>>["data"]>;
export type Session = SessionData | { session: null; user: null };
That | { session: null; user: null } is the concession you have to make. The previous SDK returned a bare nullable and half the code did session?.user. Normalizing it broke compilation everywhere that assumed the session could be undefined. That's the expensive part of the migration, not the login.
What I gain and what I lose writing it myself
What I gain is concrete: control over the session object, over where the cookie is stored, and over exactly what happens when there's no session. Plus something I hadn't valued until I needed it: being able to put authorization rules in the same place.
In a BI tool for a large company, authorization ended up being a list of emails in the middleware itself:
const authorizedEmails = [ /* ... */ ];
const hasFullAccess = userEmail && authorizedEmails.includes(userEmail);
With two different redirects: the unauthenticated user goes to login, the authenticated one without permission goes to the home page. With a managed provider you do that by fighting its claims or its dashboard. Here I wrote it in ten lines. I also left a console.log("User email:", userEmail) in, which had to be removed in a later commit, and that app had its auth rewritten three times in one month.
What I lose is everything the provider did that you never saw: token rotation, session invalidation, the recovery email, login rate limiting. None of it is impossible, but it becomes my code, with no tests except the ones I write.
My rule now
It isn't "write your own auth". It's more boring: assume from day one that you're going to change provider. What I do today before installing anything:
- A single
getSession()function in my code. No component imports the SDK. I define the return type, I don't infer it from the provider. That's the difference between migrating thirty files and migrating one. - The
user_idin my database is mine. The provider's goes in a separate column. Otherwise the migration stops being a library swap and becomes a data migration. - Login URLs in a constant, not in the provider's config. So
/asshas nowhere to hide. - Authorization never inside the provider. Who can get in is the provider's business; what they can do is my code's.
I started a new project recently and again began with a managed provider. On day one you couldn't log in: the auth base URL pointed at a placeholder value from the boilerplate. The cycle starts over, and this time I know exactly where it's going to hurt.
