Authentication

The auth option signs people in and fills ctx.user on every request: the signed-in person, or undefined. It never blocks a request on its own; what each person may do is authorization. For a first working login, follow Sign in with GitHub using OAuth.

export default server({ auth: 'cookie:github' });   // GitHub, no database
export default server({ auth: { providers: 'github', onLogin, getUser } });
OptionDescription
providersWho people sign in with: 'github', a list, or settings per provider.
strategyWhere the credential rides: 'session' (default), 'cookie', 'token' or 'jwt'.
expiresHow long a credential stays valid, '30d' by default.
onLoginStores whoever just signed in, and returns the id the credential points at.
getUserTurns that id into your user, which becomes ctx.user.
toPublicUserPicks what gets signed into a cookie or jwt credential.
onLogoutEnds the login on your side, on POST /auth/logout.
redirectWhere people land after signing in, signing out, or a failed login.
issuerChecks tokens from another service (Clerk, Supabase, Auth0...) instead of running a login.
audienceWho those tokens must be for. Required with issuer.
audienceClaimThe claim holding the audience, 'aud' by default.
cookieReads the token from this cookie instead of Authorization: Bearer.

auth also takes a string shorthand like 'cookie:github', a function, or a Better Auth instance. The credential itself is on ctx.auth.

providers

Who people can sign in with. Email and password is out of scope; for that, pass a Better Auth instance instead.

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: { scopes: ['repo'] },
  google: { prompt: 'consent' },
  work: 'https://keycloak.company.com/realms/main',
},
KeyMeaningDefault
clientIdOAuth client ID<PROVIDER>_CLIENT_ID from the environment
clientSecretOAuth client secret<PROVIDER>_CLIENT_SECRET from the environment
scopesstring 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.

For an issuer of your own, the key name is yours, and it drives three things at once: the route (/auth/login/work), the environment variables (WORK_CLIENT_ID, WORK_CLIENT_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_CLIENT_ID, ...
  customers: 'https://auth.example.com/realms/customers',   // CUSTOMERS_CLIENT_ID, ...
},

The providers

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

providers: 'google',     // GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and nothing else

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

This table lists the name to pass in providers, the environment prefix each one reads its options from (GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_SCOPES...), and the scopes it requests when you set none:

NameEnvironmentDefault scopes
anilistANI_LIST_*Set in the app settings
appleAPPLE_*email
atlassianATLASSIAN_*read:me
auth0AUTH0_*openid, profile, email
authentikAUTHENTIK_*openid, profile, email
autodeskAUTODESK_*openid, user-profile:read
battlenetBATTLE_NET_*openid
bitbucketBITBUCKET_*Set in the app settings
boxBOX_*None
bungieBUNGIE_*None
cognitoAMAZON_COGNITO_*openid, profile, email
coinbaseCOINBASE_*wallet:user:read, wallet:user:email
discordDISCORD_*identify, email
donationalertsDONATION_ALERTS_*oauth-user-show
dribbbleDRIBBBLE_*None
dropboxDROPBOX_*account_info.read
entraMICROSOFT_ENTRA_ID_*openid, profile, email
epicgamesEPIC_GAMES_*basic_profile
etsyETSY_*email_r
facebookFACEBOOK_*public_profile, email
figmaFIGMA_*current_user:read
fortytwoFORTY_TWO_*public
giteaGITEA_*read:user
githubGITHUB_*read:user, user:email
gitlabGITLAB_*openid, profile, email
googleGOOGLE_*openid, profile, email
intuitINTUIT_*openid, profile, email
kakaoKAKAO_*profile_nickname, profile_image, account_email
keycloakKEYCLOAK_*openid, profile, email
kickKICK_*user:read
lichessLICHESS_*email:read
lineLINE_*openid, profile, email
linearLINEAR_*read
linkedinLINKEDIN_*openid, profile, email
mastodonMASTODON_*read:accounts
mercadolibreMERCADO_LIBRE_*Set in the app settings
mercadopagoMERCADO_PAGO_*Set in the app settings
myanimelistMY_ANIME_LIST_*Set in the app settings
naverNAVER_*Set in the app settings
notionNOTION_*Set in the app settings
oktaOKTA_*openid, profile, email
osuOSU_*identify
patreonPATREON_*identity, identity[email]
polarPOLAR_*openid, profile, email
redditREDDIT_*identity
robloxROBLOX_*openid, profile
salesforceSALESFORCE_*openid, profile, email
shikimoriSHIKIMORI_*Set in the app settings
slackSLACK_*openid, profile, email
spotifySPOTIFY_*user-read-email, user-read-private
startggSTART_GG_*user.identity, user.email
stravaSTRAVA_*read
tiktokTIKTOK_*user.info.basic
tiltifyTILTIFY_*public
tumblrTUMBLR_*basic
twitchTWITCH_*user:read:email
twitterTWITTER_*users.read, tweet.read
vkVK_*email
withingsWITHINGS_*user.info
workosWORKOS_*Set in the app settings
yahooYAHOO_*openid, profile, email
yandexYANDEX_*login:info, login:email, login:avatar
zoomZOOM_*user:read:user

Providers listed as "Set in the app settings" take their scopes from the app you registered with them, so they ignore scopes.

Some need more options, mostly the URL of your own account or server:

  • apple: teamId, keyId (required), pkcs8PrivateKey (required, a Uint8Array of the .p8 key; option only)
  • auth0: domain (required)
  • authentik: baseURL (required)
  • bungie: apiKey
  • cognito: domain (required)
  • entra: tenant (required, or 'common')
  • gitea: baseURL (required)
  • gitlab: baseURL (required, https://gitlab.com for the public one)
  • keycloak: realmURL (required)
  • mastodon: baseURL (required)
  • okta: domain (required), authorizationServerId
  • salesforce: domain (required)

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. The name you use is the route, while the variables are always the provider's own: providers: 'cognito' mounts /auth/login/cognito and reads AMAZON_COGNITO_CLIENT_ID.

A required option can come from the environment too, under the same prefix: AUTH0_DOMAIN, KEYCLOAK_REALM_URL, GITLAB_BASE_URL, APPLE_TEAM_ID. So can the scopes, as <PROVIDER>_SCOPES, separated by commas or spaces.

providers: {
  github: { scopes: ['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_CLIENT_ID, ...

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

strategy

Where the credential rides, and what it holds:

strategyCarried inHoldsPer requestWhat logout invalidates
'session' (default)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
  • '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 and token put an id in the credential, so they always need getUser. cookie and jwt sign the user into it: the profile when there are no callbacks, or getUser and toPublicUser at login.

The cookie strategies need the frontend and the API on the same site (app.example.com calling api.example.com is fine): the cookie is SameSite=Lax, so browsers do not send it on requests from another domain. For a frontend on a different domain, use token or jwt.

expires

How long a credential stays valid, from when it was issued. '30d' by default:

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

onLogin

(profile, ctx) => id. Runs once per login, with the profile and the request. Return the id the credential should point at, usually your user's id, or throw a ServerError to refuse:

import { ServerError } from "@server/next";

onLogin: async (profile, ctx) => {
  const existing = await db.users.find({ email: profile.email });
  if (existing?.banned) throw new ServerError('ACCOUNT_SUSPENDED', 403);
  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;
},

A refused visitor lands on redirect.error with a code, and that page turns it into words. Only codes travel in the URL, never text, so show a generic message for any code you do not recognise:

?error=When
your ServerError's codeonLogin refused them
ACCESS_DENIED (or another OAuth code)they cancelled at the provider
LOGIN_FAILEDanything else: a bug, the provider down. It is logged

profile.provider says which provider this login came through, so whether a GitHub and a Google login with the same email share a row is up to your query.

Return a session id instead of a user id and every login gets its own row, which people can list and end:

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),

The profile

What onLogin receives, the same shape from every provider:

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

Anything outside that set is in raw: a GitHub company, a Keycloak groups claim, a Google hd domain.

Store accessToken to call the provider's API on their behalf later, with the scopes you asked for:

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, ctx) => user. Turns the id from onLogin into your user, which becomes ctx.user:

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

Under session and token it runs on every request, so a changed role applies on the next one. Return undefined and that credential is signed out: deleting the row (or the session) revokes the login. Under cookie and jwt it runs once, at login, feeding toPublicUser.

With issuer there is no onLogin, and it receives the token's sub claim instead, so ctx.user is your row rather than the claims:

getUser: (id) => db.users.byExternalId(id),

toPublicUser

(user) => publicUser. For cookie and jwt, which sign the user into the credential. It picks what gets signed, which is what ctx.user is on later requests and what the client can read:

toPublicUser: (user) => ({ id: user.id, email: user.email, role: user.role }),

It is required with getUser under those two strategies, so the whole row is never signed by accident.

onLogout

(id, ctx) => void. Runs on POST /auth/logout with the same id getUser receives. The credential is cleared either way; this is where you end it on the server too:

onLogout: (id) => db.sessions.delete(id),                 // this login
onLogout: async (id) => {                                 // every login
  const session = await db.sessions.find(id);
  if (session) await db.sessions.deleteAll(session.userId);
},

Without it, a copy of the credential taken before signing out keeps working until it expires. Under jwt that is always the case, since a token is checked by its signature alone.

redirect

Where the browser goes after signing in (login), signing out (logout) and a failed login (error). All default to '/':

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

A bare string or function sets login only. Never build the target from request input: under jwt the redirect carries the token, so an attacker-chosen target sends it off-site.

issuer

Checks a token minted by another service instead of running a login. When Clerk, Supabase, Firebase or Auth0 signed the person in, their SDK sends a token with every request; point at the issuer, and nothing is mounted:

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

export default server({ auth })
  .get('/me', (ctx) => ctx.user);      // the token's claims
Issuerissueraudience
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

Every request checks the signature against the issuer's published keys (fetched once, and again when they rotate), plus iss, aud and the expiry. Without getUser, ctx.user is the token's claims.

By name

Clerk, Supabase and Firebase have a shorthand, '<strategy>:<vendor>', with the issuer and audience read from <NAME>_ISSUER and <NAME>_AUDIENCE:

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
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
CLERK_ISSUER=https://<slug>.clerk.accounts.dev
CLERK_AUDIENCE=https://app.example.com

Both variables are required, and a missing one is a boot error naming it. Only jwt: and cookie: apply, since session: and token: need a getUser of yours to resolve an id.

The easy mistakes:

  • Clerk tokens have no aud. The audience is your frontend origin, checked against azp; an API identifier, as you would use for Auth0, rejects every token.
  • Supabase's audience is the literal authenticated, the same for every project.
  • Firebase puts the project id in both: the issuer ends in /<project> and the audience is <project>. gcip is the same service under its Google Cloud name. Neither is google: a Firebase sub is a Firebase uid, and firebase.sign_in_provider says how they signed in.
  • Auth0, Cognito and Keycloak use the long form, since their names already mean the login providers:
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'] };

audience

Who the token must be for, as a string or a list. Required with issuer: other apps on the same issuer get validly signed tokens too, and the audience is what tells yours apart.

audienceClaim

The claim that holds the audience, 'aud' by default. With a list, the first claim present wins:

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

The named vendors set it for you.

Reads the token from this cookie instead of Authorization: Bearer, for a same-origin app whose SDK keeps it there:

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

The string form

'<strategy>:<name>', like 'cookie:github': sign in with the provider, and stay signed in with the strategy. It mounts the login routes and signs the person's id, email, name and avatar into the credential, so there is no database to set up.

Only cookie and jwt work, since they carry the profile itself. It is the same as the object form with no callbacks, which is how to add other options:

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

Nothing about a person changes until they sign in again, and signing out only clears the credential on that device. Add onLogin and getUser when you need more.

With a vendor's name instead of a provider's ('jwt:clerk'), it checks their token; see By name.

A function

(ctx) => user. For API keys, or any check you want to write yourself. It runs at most once per request, before the body is read, and ctx.user is whatever it returns, typed from it:

import server, { ServerError } from "@server/next";

const auth = async (ctx) => {
  const key = ctx.headers['x-api-key'];
  if (!key) throw new ServerError('AUTH_NO_KEY', 401, 'Send your API key in x-api-key');
  const user = await db.users.byApiKey(key);
  if (!user) throw new ServerError('AUTH_INVALID_KEY', 401, 'That API key is not valid');
  return user;
};

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

A throw fails the request with that error, so this API refuses every request without a valid key. To let anonymous visitors reach public routes, return undefined when there is no key instead, and check ctx.user in the routes that need it.

Better Auth

Better Auth is the one auth library auth takes directly. It runs its own sign-in, including email and password, so pass the instance:

import server from "@server/next";
import { betterAuth } from "better-auth";

const auth = betterAuth({
  database,
  emailAndPassword: { enabled: true },
  socialProviders: { github: { clientId, clientSecret } },
});

export default server({ auth })
  .get('/me', (ctx) => ctx.user || 401);

Its routes are mounted under its basePath (/api/auth by default), like POST /api/auth/sign-in/email, and reach it with the body unread. ctx.user is the user on its session, typed from the instance. Everything else (the session cookie, storage, its plugins) is configured in Better Auth. See the demo for a runnable version.

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
a Better Auth instancethe user on its session

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. It types the user when there is one, so the check stays:

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

const requireAdmin: Middleware<{ user: User }> = (ctx) => {
  if (ctx.user?.role !== 'admin') return 403;
};

export default router<{ user: User }>().get('/admin', (ctx) => {
  if (!ctx.user) return 401;
  return ctx.user.role;      // User
});

Routes

Mounted only by a login flow:

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 protection is built in: a short-lived HttpOnly cookie ties the callback to the browser that started the login. A same-origin fetch('/auth/login/github') works as is. A native app must open /auth/login/<provider> in the system browser rather than fetch it, or the cookie lands in the app's own jar and the callback fails with a 403.

A script that sends Accept: application/json to /auth/login/<provider> gets the provider's URL instead of a redirect, to send the person there itself:

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

Under session and cookie the callback sets the cookie and redirects to redirect.login. Under token and jwt it redirects with the credential in the URL fragment, which never reaches a server log:

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

The fragment does stay in the browser's history, so read it and clear it as soon as the page loads:

const token = new URLSearchParams(location.hash.slice(1)).get('token');
history.replaceState(null, '', location.pathname + location.search);

A native app sets redirect.login to its own scheme, redirect: 'myapp://signed-in', and the system browser hands myapp://signed-in#token=... straight back to the app.

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
The login faileda redirect to redirect.error with ?error=CODE: a thrown ServerError's code, the provider's own (ACCESS_DENIED), or LOGIN_FAILED

Environment

SECRETS=...             # signs the credential, required in production
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_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