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 } });| Option | Description |
|---|---|
providers | Who people sign in with: 'github', a list, or settings per provider. |
strategy | Where the credential rides: 'session' (default), 'cookie', 'token' or 'jwt'. |
expires | How long a credential stays valid, '30d' by default. |
onLogin | Stores whoever just signed in, and returns the id the credential points at. |
getUser | Turns that id into your user, which becomes ctx.user. |
toPublicUser | Picks what gets signed into a cookie or jwt credential. |
onLogout | Ends the login on your side, on POST /auth/logout. |
redirect | Where people land after signing in, signing out, or a failed login. |
issuer | Checks tokens from another service (Clerk, Supabase, Auth0...) instead of running a login. |
audience | Who those tokens must be for. Required with issuer. |
audienceClaim | The claim holding the audience, 'aud' by default. |
cookie | Reads 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',
},| Key | Meaning | Default |
|---|---|---|
clientId | OAuth client ID | <PROVIDER>_CLIENT_ID from the environment |
clientSecret | OAuth client secret | <PROVIDER>_CLIENT_SECRET from the environment |
scopes | string or array of strings | the provider's own |
issuer | an OIDC issuer URL | none |
| anything else | passed 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 elseThey 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:
| Name | Environment | Default scopes |
|---|---|---|
anilist | ANI_LIST_* | Set in the app settings |
apple | APPLE_* | email |
atlassian | ATLASSIAN_* | read:me |
auth0 | AUTH0_* | openid, profile, email |
authentik | AUTHENTIK_* | openid, profile, email |
autodesk | AUTODESK_* | openid, user-profile:read |
battlenet | BATTLE_NET_* | openid |
bitbucket | BITBUCKET_* | Set in the app settings |
box | BOX_* | None |
bungie | BUNGIE_* | None |
cognito | AMAZON_COGNITO_* | openid, profile, email |
coinbase | COINBASE_* | wallet:user:read, wallet:user:email |
discord | DISCORD_* | identify, email |
donationalerts | DONATION_ALERTS_* | oauth-user-show |
dribbble | DRIBBBLE_* | None |
dropbox | DROPBOX_* | account_info.read |
entra | MICROSOFT_ENTRA_ID_* | openid, profile, email |
epicgames | EPIC_GAMES_* | basic_profile |
etsy | ETSY_* | email_r |
facebook | FACEBOOK_* | public_profile, email |
figma | FIGMA_* | current_user:read |
fortytwo | FORTY_TWO_* | public |
gitea | GITEA_* | read:user |
github | GITHUB_* | read:user, user:email |
gitlab | GITLAB_* | openid, profile, email |
google | GOOGLE_* | openid, profile, email |
intuit | INTUIT_* | openid, profile, email |
kakao | KAKAO_* | profile_nickname, profile_image, account_email |
keycloak | KEYCLOAK_* | openid, profile, email |
kick | KICK_* | user:read |
lichess | LICHESS_* | email:read |
line | LINE_* | openid, profile, email |
linear | LINEAR_* | read |
linkedin | LINKEDIN_* | openid, profile, email |
mastodon | MASTODON_* | read:accounts |
mercadolibre | MERCADO_LIBRE_* | Set in the app settings |
mercadopago | MERCADO_PAGO_* | Set in the app settings |
myanimelist | MY_ANIME_LIST_* | Set in the app settings |
naver | NAVER_* | Set in the app settings |
notion | NOTION_* | Set in the app settings |
okta | OKTA_* | openid, profile, email |
osu | OSU_* | identify |
patreon | PATREON_* | identity, identity[email] |
polar | POLAR_* | openid, profile, email |
reddit | REDDIT_* | identity |
roblox | ROBLOX_* | openid, profile |
salesforce | SALESFORCE_* | openid, profile, email |
shikimori | SHIKIMORI_* | Set in the app settings |
slack | SLACK_* | openid, profile, email |
spotify | SPOTIFY_* | user-read-email, user-read-private |
startgg | START_GG_* | user.identity, user.email |
strava | STRAVA_* | read |
tiktok | TIKTOK_* | user.info.basic |
tiltify | TILTIFY_* | public |
tumblr | TUMBLR_* | basic |
twitch | TWITCH_* | user:read:email |
twitter | TWITTER_* | users.read, tweet.read |
vk | VK_* | email |
withings | WITHINGS_* | user.info |
workos | WORKOS_* | Set in the app settings |
yahoo | YAHOO_* | openid, profile, email |
yandex | YANDEX_* | login:info, login:email, login:avatar |
zoom | ZOOM_* | 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, aUint8Arrayof the .p8 key; option only)auth0:domain(required)authentik:baseURL(required)bungie:apiKeycognito:domain(required)entra:tenant(required, or'common')gitea:baseURL(required)gitlab:baseURL(required,https://gitlab.comfor the public one)keycloak:realmURL(required)mastodon:baseURL(required)okta:domain(required),authorizationServerIdsalesforce: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:
strategy | Carried in | Holds | Per request | What logout invalidates |
|---|---|---|---|---|
'session' (default) | a cookie | an opaque id | getUser(id) | whatever onLogout deletes |
'cookie' | a cookie | signed user data | nothing | this browser only |
'token' | Authorization | an opaque id | getUser(id) | whatever onLogout deletes |
'jwt' | Authorization | signed user data | nothing | nothing, 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, wonLogin
(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 code | onLogin refused them |
ACCESS_DENIED (or another OAuth code) | they cancelled at the provider |
LOGIN_FAILED | anything 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| Issuer | issuer | audience |
|---|---|---|
| Supabase | https://<ref>.supabase.co/auth/v1 | authenticated |
| Auth0 | https://<tenant>.auth0.com/ | your API identifier |
| Cognito | https://cognito-idp.<region>.amazonaws.com/<pool> | the app client id |
| Clerk | https://<slug>.clerk.accounts.dev | your frontend API |
| Keycloak | https://<host>/realms/<realm> | the client id |
https://accounts.google.com | your 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>_AUDIENCE | Claim | Cookie |
|---|---|---|---|---|
clerk | https://<slug>.clerk.accounts.dev | your frontend origin | azp | __session |
supabase | https://<ref>.supabase.co/auth/v1 | authenticated | aud | none |
firebase | https://securetoken.google.com/<project> | the project id | aud | none |
gcip | https://securetoken.google.com/<project> | the project id | aud | none |
const auth = 'jwt:clerk'; // Authorization: Bearer, for a native or cross-origin client
const auth = 'cookie:clerk'; // their __session cookie, for a same-origin appCLERK_ISSUER=https://<slug>.clerk.accounts.dev
CLERK_AUDIENCE=https://app.example.comBoth 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 againstazp; 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>.gcipis the same service under its Google Cloud name. Neither isgoogle: a Firebasesubis a Firebase uid, andfirebase.sign_in_providersays 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.
cookie
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
});auth | ctx.user |
|---|---|
a login flow with getUser | getUser's return |
| a login flow without callbacks, or the string form | the profile |
{ issuer, audience } with getUser | getUser's return |
{ issuer, audience } alone | the token's claims |
| a function | its return |
| a Better Auth instance | the 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:
| Method | Route | Does |
|---|---|---|
GET | /auth/login/<provider> | starts the handshake |
GET | /auth/callback/<provider> | finishes it, calls onLogin, issues the credential |
POST | /auth/logout | calls 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
| Situation | Status |
|---|---|
| No credential | anonymous (ctx.user is undefined) |
| Unknown or expired id | anonymous (ctx.user is undefined) |
| A tampered or expired cookie | anonymous, and the cookie is cleared so the next request starts clean. With log on, it says why |
| A tampered or expired bearer token | 401: it was attached deliberately, so the client must be told |
An Authorization header with another scheme | ignored: not ours to police |
| A token from the wrong issuer or audience | 401 |
Failed OAuth state check, or none sent | 403 |
A callback with no code | 400 |
| The login failed | a 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:
| Tutorial | What it sets up |
|---|---|
| Sign in with GitHub using OAuth | The smallest login: a cookie, no database |
| Github and Google login in one app | Two providers, one account, linked by email |
| Google login persisted in SQLite | Your own users table, with a role column |
| Github login persisted in Redis | The same two callbacks against a key-value store |
| Revocable sessions in Postgres | A row per login, so logout ends it everywhere |
| Discord login with JWT bearer tokens | The jwt strategy, for a SPA or a native app |
| Supabase auth with your own users | Verifying a hosted token, mapped to your table |
| Clerk auth in a same-origin cookie | Reading a vendor's cookie, and the azp claim |
| Firebase auth from a mobile app | Verifying Firebase and Identity Platform |
| Keycloak SSO with any OIDC issuer | Any OIDC issuer, including self-hosted |
| Github OAuth scopes and access tokens | Calling a provider's API on someone's behalf |