Authentication

Authentication answers one question: who is making this request? What that person is then allowed to do (roles, ownership, plans) is authorization, a separate job that stays as plain if statements in your handlers.

Answering it takes two steps. Someone proves who they are once, by signing in with a provider like GitHub. From then on every request carries a credential, a cookie or a token, that stands in for having done so. The auth option covers both ends: it mounts the login routes when it runs the flow, and on every request it turns the credential back into a person on ctx.user.

It never blocks anything on its own. A signed-out visitor still reaches your handler, with ctx.user undefined, and deciding what that means for a given route is yours (see Protecting routes).

Signing in with GitHub

A complete, working login:

// index.js
import server from '@server/next';

export default server({ auth: 'cookie:github' })
  .get('/', (ctx) => {
    if (!ctx.user) return <a href="/auth/login/github">Sign in with GitHub</a>;
    return <p>Hello ${ctx.user.name} <LogoutForm /></p>;
  });
# .env: create an OAuth app at github.com/settings/developers, with the
# callback URL pointing at <your-host>/auth/callback/github
SECRETS=a-long-random-string
GITHUB_ID=...
GITHUB_SECRET=...

The string 'cookie:github' is '<strategy>:<provider>': sign in with GitHub, and carry the result in a cookie. From that, four things happen without any more code:

  1. GET /auth/login/github is mounted, so the link sends people to GitHub.
  2. GitHub sends them back to GET /auth/callback/github, also mounted, which exchanges the code for their profile.
  3. A safe subset of that profile (id, email, name, avatar) is signed into an HttpOnly cookie. The access token and the raw payload stay on the server.
  4. Every later request verifies that cookie and fills ctx.user, a plain field holding a plain object. Nothing to await, no accessor to call, and no database or session store anywhere.

POST /auth/logout is mounted too, which is what the form above posts to.

There is a step by step version of it, and a tutorial per setup further down.

The rest of this page is what to reach for when you outgrow it: your own user records, any of 63 providers, tokens instead of cookies for an API or a mobile client, or a login that happened somewhere else entirely.

ctx.auth

Alongside ctx.user, ctx.auth describes the credential rather than the person. It comes straight out of the credential we verified, with no lookup behind it:

ctx.auth   // { issuedAt: Date, expiresAt?: Date, strategy?, provider? }
.post('/account/delete', (ctx) => {
  if (!ctx.user) return 401;
  // Ask them to sign in again before something irreversible
  const age = Date.now() - ctx.auth.issuedAt.getTime();
  if (age > 15 * 60 * 1000) return 403;
  return db.users.delete(ctx.user.id);
})

provider is who vouched for them, so an app accepting several can branch on it. strategy is how this request authenticated, which matters when more than one is accepted.

It is undefined when there is no credential, and also for the function and library shapes, where there is nothing of ours to read.

The shapes of auth

Whichever way people sign in, it ends the same way, in ctx.user:

const auth = 'cookie:github';                      // a login flow, no database
const auth = { providers: 'github', ... };         // a login flow, your database
const auth = 'jwt:clerk';                          // a token a vendor issued
const auth = { issuer: ISSUER, audience: 'api' };  // ...or any issuer by URL
const auth = (ctx) => db.users.byApiKey(...);      // anything else, your way
const auth = betterAuth({ database });             // a library that does it all

export default server({ auth });

No database

The example above is the whole feature: with no callbacks there is nowhere to read a person from, so the profile itself is signed into the credential and ctx.user is that profile on every later request.

The same thing written out, when you want to set other options:

const auth = { providers: 'github', strategy: 'cookie', redirect: '/app' };

export default server({ auth });

Because it takes no callbacks, it only works with the two strategies that sign the person into the credential, cookie and jwt. The other two put an id there and need something of yours to resolve it.

What you give up: nothing about a person can change until they sign in again, there is no role column to check, and signing out only clears the cookie on the device that asked. Add a database when you need any of those.

A login flow with your database

The framework runs the handshake and issues the credential; you decide who gets stored, and where.

const auth = {
  providers: 'github',
  onLogin: async (profile) => (await db.users.upsert({ email: profile.email })).id,
  getUser: (id) => db.users.find(id),
};

export default server({ auth })
  .get('/me', (ctx) => ctx.user);      // your own row
KeyTypeRequired, or default
providersstring, string[] or an objectrequired
strategy'session' | 'cookie' | 'token' | 'jwt''session'
expiresduration'30d'
onLogin(profile, ctx) => idwith getUser
getUser(id, ctx) => useralways for session and token
toPublicUser(user) => publicUserwith getUser, for cookie and jwt
onLogout(id, ctx) => voidnone
redirectstring, function, or an object'/'

The callbacks are all or nothing, and the group can only be dropped for cookie and jwt: with no database there is nothing for getUser to read, so the profile is signed as-is. session and token put an id in the credential, and something has to resolve it.

How a login works

Worth reading once, because it explains the shape of the callbacks:

  1. The handshake finishes and produces a normalised profile.
  2. onLogin(profile, ctx) stores whoever that is, and returns the id the credential will point at.
  3. For session and token that id goes straight into the credential. For cookie and jwt the framework calls getUser(id) and then toPublicUser(user), and signs the result instead.
  4. On later requests, session and token call getUser(id, ctx); cookie and jwt just read what was signed.

Each callback means exactly one thing in all four strategies: onLogin identifies, getUser resolves, toPublicUser trims. Changing strategy never changes what your callbacks do, only how often they run.

Providers

Three spellings, from shortest to most explicit:

providers: 'github',
providers: ['github', 'google'],
providers: { github: {}, google: {} },

The object form is the one that takes settings, and a bare string value is shorthand for an issuer:

providers: {
  github: { scope: ['repo'] },
  google: { prompt: 'consent' },
  work: 'https://keycloak.company.com/realms/main',
},
KeyMeaningDefault
idOAuth client id<NAME>_ID from the environment
secretOAuth client secret<NAME>_SECRET from the environment
scopestring or array of stringsthe provider's own
issueran OIDC issuer URLnone
anything elsepassed through to the authorize URL

Passthrough is how prompt, team and tenant work without the framework knowing they exist.

The key name is yours, and it drives three things at once: the route (/auth/login/work), the environment variables (WORK_ID, WORK_SECRET), and profile.provider, which is what onLogin branches on. So two issuers is just two names:

providers: {
  staff: 'https://auth.example.com/realms/employees',       // STAFF_ID, STAFF_SECRET
  customers: 'https://auth.example.com/realms/customers',   // CUSTOMERS_ID, CUSTOMERS_SECRET
},

The providers

63 of them, so a name and two environment variables is usually the whole integration:

providers: 'google',     // GOOGLE_ID, GOOGLE_SECRET, and nothing else

They come from antarctic, which owns each provider's endpoints, token exchange and profile mapping.

Every provider reads its credentials from the environment by its own name, and takes scope plus anything else passed through. The tenant-specific ones need one more option, since their URL differs per account:

NameEnvironmentOptions beyond scope
cognitoCOGNITO_ID, COGNITO_SECRETdomain (required)
anilistANILIST_ID, ANILIST_SECRET
appleAPPLE_ID, APPLE_SECRETteamId, keyId (required), pkcs8PrivateKey (required, a Uint8Array of the .p8 key; option only)
atlassianATLASSIAN_ID, ATLASSIAN_SECRET
auth0AUTH0_ID, AUTH0_SECRETdomain (required)
authentikAUTHENTIK_ID, AUTHENTIK_SECRETbaseURL (required)
autodeskAUTODESK_ID, AUTODESK_SECRET
battlenetBATTLENET_ID, BATTLENET_SECRET
bitbucketBITBUCKET_ID, BITBUCKET_SECRET
boxBOX_ID, BOX_SECRET
bungieBUNGIE_ID, BUNGIE_SECRETapiKey
coinbaseCOINBASE_ID, COINBASE_SECRET
discordDISCORD_ID, DISCORD_SECRET
donationalertsDONATIONALERTS_ID, DONATIONALERTS_SECRET
dribbbleDRIBBBLE_ID, DRIBBBLE_SECRET
dropboxDROPBOX_ID, DROPBOX_SECRET
epicgamesEPICGAMES_ID, EPICGAMES_SECRET
etsyETSY_ID, ETSY_SECRET
facebookFACEBOOK_ID, FACEBOOK_SECRET
figmaFIGMA_ID, FIGMA_SECRET
fortytwoFORTYTWO_ID, FORTYTWO_SECRET
giteaGITEA_ID, GITEA_SECRETbaseURL (required)
githubGITHUB_ID, GITHUB_SECRET
gitlabGITLAB_ID, GITLAB_SECRETbaseURL (required, https://gitlab.com for the public one)
googleGOOGLE_ID, GOOGLE_SECRET
intuitINTUIT_ID, INTUIT_SECRET
kakaoKAKAO_ID, KAKAO_SECRET
keycloakKEYCLOAK_ID, KEYCLOAK_SECRETrealmURL (required)
kickKICK_ID, KICK_SECRET
lichessLICHESS_ID, LICHESS_SECRET
lineLINE_ID, LINE_SECRET
linearLINEAR_ID, LINEAR_SECRET
linkedinLINKEDIN_ID, LINKEDIN_SECRET
mastodonMASTODON_ID, MASTODON_SECRETbaseURL (required)
mercadolibreMERCADOLIBRE_ID, MERCADOLIBRE_SECRET
mercadopagoMERCADOPAGO_ID, MERCADOPAGO_SECRET
entraENTRA_ID, ENTRA_SECRETtenant (required, or 'common')
myanimelistMYANIMELIST_ID, MYANIMELIST_SECRET
naverNAVER_ID, NAVER_SECRET
notionNOTION_ID, NOTION_SECRET
oktaOKTA_ID, OKTA_SECRETdomain (required), authorizationServerId
osuOSU_ID, OSU_SECRET
patreonPATREON_ID, PATREON_SECRET
polarPOLAR_ID, POLAR_SECRET
redditREDDIT_ID, REDDIT_SECRET
robloxROBLOX_ID, ROBLOX_SECRET
salesforceSALESFORCE_ID, SALESFORCE_SECRETdomain (required)
shikimoriSHIKIMORI_ID, SHIKIMORI_SECRET
slackSLACK_ID, SLACK_SECRET
spotifySPOTIFY_ID, SPOTIFY_SECRET
startggSTARTGG_ID, STARTGG_SECRET
stravaSTRAVA_ID, STRAVA_SECRET
tiktokTIKTOK_ID, TIKTOK_SECRET
tiltifyTILTIFY_ID, TILTIFY_SECRET
tumblrTUMBLR_ID, TUMBLR_SECRET
twitchTWITCH_ID, TWITCH_SECRET
twitterTWITTER_ID, TWITTER_SECRET
vkVK_ID, VK_SECRET
withingsWITHINGS_ID, WITHINGS_SECRET
workosWORKOS_ID, WORKOS_SECRET
yahooYAHOO_ID, YAHOO_SECRET
yandexYANDEX_ID, YANDEX_SECRET
zoomZOOM_ID, ZOOM_SECRET

cognito and entra are Amazon Cognito and Microsoft Entra ID, shortened to the word that identifies them. The full amazoncognito and microsoftentraid work too, as does microsoft, and paypal by way of its OIDC issuer. Whichever name you use is the one that names the environment variables and the route, so providers: 'cognito' reads COGNITO_ID and mounts /auth/login/cognito.

A required option can come from the environment too, under that provider's own prefix: AUTH0_DOMAIN, KEYCLOAK_REALM_URL, GITLAB_BASE_URL, APPLE_TEAM_ID.

providers: {
  github: { scope: ['repo'] },
  auth0: { domain: 'acme.auth0.com' },
  keycloak: { realmURL: 'https://sso.company.com/realms/main' },
  mastodon: { baseURL: 'https://fosstodon.org' },
},

PKCE is handled for the providers that use it: the code_verifier never travels in the URL, it rides in the same signed cookie as the CSRF state.

Any OIDC issuer

A provider we do not ship, or a tenant-specific one (Keycloak realms, self-hosted Authentik, a private Okta), takes an issuer URL instead. It publishes everything needed at <issuer>/.well-known/openid-configuration, so there is nothing else to configure:

providers: { work: 'https://keycloak.company.com/realms/main' },   // WORK_ID, WORK_SECRET

Claims outside the standard set, like group memberships or a namespaced role, arrive in profile.raw.

Strategies

Where the credential rides, and what it holds:

strategyCarried inHoldsPer requestWhat logout invalidates
'session'a cookiean opaque idgetUser(id)whatever onLogout deletes
'cookie'a cookiesigned user datanothingthis browser only
'token'Authorizationan opaque idgetUser(id)whatever onLogout deletes
'jwt'Authorizationsigned user datanothingnothing, the client drops it

A shorter way to hold it:

  • 'session': browser, and the server remembers
  • 'cookie': browser, and the server forgets
  • 'token': API client, and the server remembers
  • 'jwt': API client, and the server forgets

"Session" here means a server-side session referenced by a cookie, not the browser's own notion of a cookie that dies when the window closes; ours lives as long as expires.

The two "remembers" strategies put an opaque id in the credential and resolve it through getUser on every request, so anything you change lands immediately. The two "forgets" strategies sign the user in once, so there is nothing to look up and nothing to revoke.

strategy is one value: an app issues and reads one kind of credential. Several login options are several providers inside it, not several strategies.

expires

expires: '30d',    // s, m, h, d, w

How long a credential stays valid, measured from when it was issued.

Callbacks

onLogin

Runs once, after a successful handshake. Receives the profile and the pre-login request, and returns the id the credential should point at.

onLogin: async (profile, ctx) => {
  const existing = await db.users.find({ email: profile.email });
  if (existing?.banned) throw new Error('Your account is suspended');
  await db.carts.move(ctx.cookies.cart, existing?.id);    // merge anonymous state
  const user = await db.users.upsert(
    { email: profile.email },
    { [`${profile.provider}Id`]: profile.id, name: profile.name },
  );
  return user.id;
},

Refuse a login by throwing. The message reaches redirect.error as ?error=, so write it for the person reading it. Only onLogin's own throws are shown: any other failure in the callback (the provider down, a bug) is logged for the operator and appears to the visitor as a generic "Could not sign you in". It is never "return nothing": a missing return is undefined in JavaScript, so treating that as a denial would turn an ordinary bug into people being rejected with no stated reason.

Account linking is this function's WHERE clause: profile.provider tells you which one you are being handed, so whether two providers collapse into one row is your decision, not a framework setting.

What the id points at

The framework has no opinion about what the id identifies, and that is where the flexibility lives. Return a user id and the credential points at a person; return a session id and it points at one login.

// One login per person. No session table. Logging out clears this device's
// cookie; a copy taken beforehand keeps working until it expires.
onLogin: async (profile) => (await db.users.upsert({ email: profile.email })).id,
getUser: (id) => db.users.find(id),

// One row per login. Logout means something, and people can see and end
// their own sessions.
onLogin: async (profile, ctx) => {
  const user = await db.users.upsert({ email: profile.email });
  const session = await db.sessions.create({
    userId: user.id,
    userAgent: ctx.headers['user-agent'],
  });
  return session.id;
},
getUser: (id) => db.sessions.findUser(id),      // joins back through userId
onLogout: (id) => db.sessions.delete(id),

Same callbacks, pointed at a different table. The link between the cookie and the person is the userId column on your own sessions row.

The profile

Normalised across providers, so adding one is not a code change:

{
  provider: 'github',
  id: '583231',
  email, name, avatar,
  accessToken, refreshToken?,
  raw,             // the untouched response, for provider-specific fields
}

raw is how anything outside that set reaches you: a GitHub company, a Keycloak groups claim, a Google hd domain.

accessToken is what makes scope worth asking for. Store it in onLogin to call the provider's API later:

const auth = {
  providers: { github: { scope: ['repo'] } },
  onLogin: async (profile) => {
    const user = await db.users.upsert({ email: profile.email });
    await db.tokens.set(user.id, profile.accessToken);
    return user.id;
  },
  getUser: (id) => db.users.find(id),
};

getUser

Turns the id from onLogin into the user. For session and token it runs on every request; for cookie and jwt it runs once at login, feeding toPublicUser.

getUser: (id, ctx) => db.users.find(id),

It is named for what it returns, which is always the user; what it receives is whatever onLogin identified. On a later request, returning undefined means "no such user any more" and signs that credential out: that is how a deleted row or session revokes a login. At login time it can never be right, since onLogin just stored that id, so a nullish return there fails the login rather than minting an empty credential.

Running per request is what keeps things honest: change a role and it applies on the next request, return undefined and that person is signed out everywhere at once. The cost is one lookup per request, which is yours to cache.

That is control over the user, not over one credential. Invalidating a single leaked cookie while leaving that person's other devices alone needs the credential to be something you can point at and delete, which is the session shape above.

toPublicUser

Runs once, at login, for cookie and jwt. Takes what getUser returned and produces what gets signed into the credential, which is both what ctx.user will be on later requests and what the client can read.

const auth = {
  providers: 'github',
  strategy: 'cookie',
  onLogin: async (profile) => (await db.users.upsert({ email: profile.email })).id,
  getUser: (id) => db.users.find(id),
  toPublicUser: (user) => ({ id: user.id, email: user.email, role: user.role }),
};

Your row goes in, the public subset comes out, and no database is touched on later requests. It is required rather than defaulted, because defaulting it to "sign the whole row" would quietly publish whatever else is on it.

onLogout

Runs on POST /auth/logout, with the same id getUser receives. The credential is cleared either way; this is for anything of yours that should go with it.

// With a session id in the credential: end that login, or all of them
onLogout: (id) => db.sessions.delete(id),
onLogout: async (id) => {
  const session = await db.sessions.find(id);
  if (session) await db.sessions.deleteAll(session.userId);
},

Omit it and logging out is local: the browser forgets the credential, and a copy taken beforehand keeps working until it expires. For a small app that is often the right trade, but it should be a decision rather than a surprise.

redirect

redirect: '/app',
redirect: (user) => (user.role === 'admin' ? '/admin' : '/app'),
redirect: {
  login: '/app',
  logout: '/',
  error: '/login?failed',
},

A bare string or function sets login only. Each slot takes either.

Do not build the target from request input: under jwt the redirect carries the token in the fragment, so an attacker-influenced target ships the credential off-site.

Checking a token minted elsewhere

When the login happened somewhere else, there is no handshake to run and no routes to mount. Their SDK signed the person in, and every request arrives carrying a token:

Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImE0MiJ9...

Point at the issuer and say who the token should be for:

const auth = { issuer: 'https://xyz.supabase.co/auth/v1', audience: 'authenticated' };

export default server({ auth })
  .get('/me', (ctx) => ctx.user);      // the token's claims

That covers most hosted auth, because they all publish their keys the same way:

Issuerverifyaudience
Supabasehttps://<ref>.supabase.co/auth/v1authenticated
Auth0https://<tenant>.auth0.com/your API identifier
Cognitohttps://cognito-idp.<region>.amazonaws.com/<pool>the app client id
Clerkhttps://<slug>.clerk.accounts.devyour frontend API
Keycloakhttps://<host>/realms/<realm>the client id
Googlehttps://accounts.google.comyour OAuth client id
KeyTypeRequired, or default
issuerissuer URLrequired
audiencestring or array of stringsrequired
cookiestringnone: reads Authorization: Bearer
audienceClaimstring or array of strings'aud'
getUser(id, ctx) => usernone: ctx.user is the claims

The issuer's discovery document gives the key set, which is fetched once and cached by key id (an unknown key id triggers one refetch, so a rotated key does not need a restart). Every request then checks four things: the signature, that iss matches your issuer, that aud matches your audience, and that the token has not expired.

Not every issuer puts the audience in aud. Clerk carries the authorized party in azp and no aud at all; Cognito uses aud on id tokens and client_id on access tokens. audienceClaim names which to check, and with a list the first claim present wins:

const auth = { issuer: ISSUER, audience: 'https://app.example.com', audienceClaim: 'azp' };

The vendors below set this for you.

audience is required and has no default, deliberately. One issuer usually serves several applications, all signed with the same keys, so a token minted for a different app carries a valid signature and the same iss. The audience is the only claim that separates them.

By name

The vendors above have a shorthand, since the only thing that varies is where their token rides:

const auth = 'jwt:clerk';      // Authorization: Bearer, for a native or cross-origin client
const auth = 'cookie:clerk';   // their __session cookie, for a same-origin app

The prefix means the same thing it means everywhere: jwt is a signed token in the Authorization header, cookie is one in a cookie. Only the keys that verify it change, from your secrets to the issuer's published ones. session: and token: are not possible here, since an opaque id needs a getUser of yours to resolve it.

Their issuer and audience differ per account, so they come from the environment. Both are required, and a missing one is a boot error naming it:

Name<NAME>_ISSUER<NAME>_AUDIENCEClaimCookie
clerkhttps://<slug>.clerk.accounts.devyour frontend originazp__session
supabasehttps://<ref>.supabase.co/auth/v1authenticatedaudnone
firebasehttps://securetoken.google.com/<project>the project idaudnone
gciphttps://securetoken.google.com/<project>the project idaudnone

Each row encodes the thing that is easy to get wrong:

  • Clerk session tokens carry no aud at all. The authorized party (your frontend origin, like https://app.example.com) is in azp, which is what their own backend SDK checks. Setting audience to an API identifier, as you would for Auth0, rejects every token.
  • Supabase uses the literal string authenticated as the audience. It is the role name, the same for every project, not something from your dashboard.
  • Firebase and GCIP put the project id in both halves: the issuer is https://securetoken.google.com/<project> and aud is <project>. The keys live at a [email protected] URL that discovery finds for you.
  • Cognito, if you reach for it through the long form, splits across two claims: id tokens use aud, access tokens use client_id. Pass audienceClaim: ['aud', 'client_id'] to take either.

firebase and gcip are the same service (Google Cloud Identity Platform is Firebase Authentication under its enterprise name), so both spellings read their own variables.

Neither is google, which is a different product. Google is an identity provider: it owns the accounts, and signing in means using a Google account. Firebase is an identity platform: it owns your user base, and those people may have signed in with email and password, a phone number, Apple or Google. So a Firebase token's sub is a Firebase uid rather than a Google account id, and its firebase.sign_in_provider claim says which method they actually used.

Auth0, Cognito and Keycloak are absent on purpose: they are providers you can log in with, so a name would mean two different things. Verifying their tokens uses the long form, which is one line either way:

const auth = { issuer: 'https://acme.auth0.com/', audience: 'https://api.example.com' };
const auth = { issuer: COGNITO_POOL_URL, audience: CLIENT_ID, audienceClaim: ['aud', 'client_id'] };

"Cookie" is where that vendor's SDK stores the token for a same-origin app. The ones with none keep it in memory or localStorage, so their client sends it as a header and only jwt: applies. To read a cookie they do not name by convention, use the long form:

const auth = {
  issuer: process.env.SUPABASE_ISSUER,
  audience: 'authenticated',
  cookie: 'sb-xyz-auth-token',
};

Your own row

Without getUser, ctx.user is the claims: sub, email, and whatever else that issuer includes. Claims are not a user row, so there is no role column and no id of yours to compare against.

Add getUser and it means the same thing it means everywhere else, turning an id into a user. The id here is the sub claim:

const auth = {
  issuer: 'https://xyz.supabase.co/auth/v1',
  audience: 'authenticated',
  getUser: (id, ctx) => db.users.byExternalId(id),
};

Now ctx.user is your row, so ownership checks compare file.userId against ctx.user.id exactly as they do under a login flow, and the rest of your app cannot tell which shape you configured.

A function

For credentials that are not JWTs, or checks you want to write yourself:

const auth = (ctx) => db.users.byApiKey(ctx.headers['x-api-key']);

ctx.user is typed as whatever it returns, and it runs at most once per request. Return undefined for "not logged in", which is not an error: handlers decide whether that deserves a 401. To fail the request instead, throw an error carrying a status:

throw new ServerError('AUTH_EXPIRED_TOKEN', 401, 'Your session expired');

A library

Some libraries run their own handshake and serve their own routes. Pass the instance:

const auth = betterAuth({ database, socialProviders: { github } });

export default server({ auth });

Its whole route prefix is mounted and ctx.user comes from its session. The prefix is the library's own, so moving it is a setting there, not here.

Those routes are a passthrough: the body reaches them unread, so the framework never consumes bytes the library needs to parse or verify itself.

Protecting routes

Auth only resolves the credential and sets ctx.user; it never rejects a request on its own. Guard the routes you want to protect with a ctx.user check:

export default server({ auth })
  .get('/public', () => 'anyone')

  .get('/account', (ctx) => {
    if (!ctx.user) return 401;
    return `Hello ${ctx.user.email}`;
  })

  .get('/admin', (ctx) => {
    if (!ctx.user) return 401;
    if (ctx.user.role !== 'admin') return 403;
    return db.reports.all();
  })

  .get('/files/:id', async (ctx) => {
    if (!ctx.user) return 401;
    const file = await db.files.find(ctx.url.params.id);
    if (!file) return 404;
    if (file.userId !== ctx.user.id) return 403;
    return file;
  });

Inline checks are the recommended shape: they narrow ctx.user's type for the rest of the handler, which a guard middleware cannot do. For a check repeated across many routes, put it in a middleware and keep the inline check where the handler needs the narrowed type.

Typing ctx.user

ctx.user is typed from your own auth, so an app never declares a User type:

server({ auth: { providers: 'github', onLogin, getUser: (id) => db.users.find(id) } })
  .get('/admin', (ctx) => {
    if (!ctx.user) return 401;
    ctx.user.role;      // typed from getUser's return
    ctx.user.plan;      // compile error
  });
authctx.user
a login flow with getUsergetUser's return
a login flow without callbacks, or the string formthe profile
{ issuer, audience } with getUsergetUser's return
{ issuer, audience } alonethe token's claims
a functionits return
an arraythe union of its members

It is always possibly undefined, since that is what being signed out means, so it has to be checked before use.

For handlers and middleware in other files, there is no server() call to infer from, so declare it there instead:

import { router, type Middleware } from '@server/next';

const requireAdmin: Middleware<{ user: User }> = (ctx) => { ... };
export default router<{ user: User }>().get('/admin', (ctx) => ctx.user.role);

Routes

Mounted only by a login flow. A verify, a function and a library instance mount nothing of ours.

MethodRouteDoes
GET/auth/login/<provider>starts the handshake
GET/auth/callback/<provider>finishes it, calls onLogin, issues the credential
POST/auth/logoutcalls onLogout, clears the credential

CSRF state is handled for you, under every strategy. The callback is always a browser navigation, even when the client holds the credential, so it is always bound to the browser that started the login: without that, someone can be walked through a callback carrying an attacker's code and end up signed in as the attacker.

That binding is a short-lived HttpOnly cookie, which has one consequence for clients. A same-origin fetch('/auth/login/github') stores it and the browser presents it at the callback, so SPAs work as they are. A native app must navigate the system browser to /auth/login/<provider> rather than fetching the URL from its own HTTP client: fetching it puts the cookie in the app's jar instead of the browser's, and the callback then fails with a 403.

/auth/login/<provider> answers by what the caller asked for. A browser follows the 302 to the provider; a script that sends Accept: application/json gets the URL instead and sends the person there itself:

{ "url": "https://github.com/login/oauth/authorize?client_id=..." }

The callback is always hit by the browser, because that is where the provider sends people back, so its shape follows the strategy instead. Under session and cookie it sets a cookie and redirects to redirect.login. Under token and jwt there is no cookie to set, so it redirects carrying the credential in the URL fragment:

https://example.com/app#token=eyJhbGciOi...

A fragment rather than a query string, because browsers never send it to a server, so it stays out of access logs and referrer headers.

Responses

SituationStatus
No credentialanonymous (ctx.user is undefined)
Unknown or expired idanonymous (ctx.user is undefined)
A tampered or expired cookieanonymous, and the cookie is cleared so the next request starts clean. With log on, it says why
A tampered or expired bearer token401: it was attached deliberately, so the client must be told
An Authorization header with another schemeignored: not ours to police
A token from the wrong issuer or audience401
Failed OAuth state check, or none sent403
A callback with no code400
onLogin threwa redirect to redirect.error with ?error=

Environment

SECRETS=...             # signs the credential, required in production
GITHUB_ID=...
GITHUB_SECRET=...

See secrets for rotating a key without signing everyone out.

Tutorials

Each one is a single working setup, start to finish:

TutorialWhat it sets up
Sign in with GitHub using OAuthThe smallest login: a cookie, no database
Github and Google login in one appTwo providers, one account, linked by email
Google login persisted in SQLiteYour own users table, with a role column
Github login persisted in RedisThe same two callbacks against a key-value store
Revocable sessions in PostgresA row per login, so logout ends it everywhere
Discord login with JWT bearer tokensThe jwt strategy, for a SPA or a native app
Supabase auth with your own usersVerifying a hosted token, mapped to your table
Clerk auth in a same-origin cookieReading a vendor's cookie, and the azp claim
Firebase auth from a mobile appVerifying Firebase and Identity Platform
Keycloak SSO with any OIDC issuerAny OIDC issuer, including self-hosted
Github OAuth scopes and access tokensCalling a provider's API on someone's behalf