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:
GET /auth/login/githubis mounted, so the link sends people to GitHub.- GitHub sends them back to
GET /auth/callback/github, also mounted, which exchanges the code for their profile. - A safe subset of that profile (
id,email,name,avatar) is signed into anHttpOnlycookie. The access token and the raw payload stay on the server. - 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| Key | Type | Required, or default |
|---|---|---|
providers | string, string[] or an object | required |
strategy | 'session' | 'cookie' | 'token' | 'jwt' | 'session' |
expires | duration | '30d' |
onLogin | (profile, ctx) => id | with getUser |
getUser | (id, ctx) => user | always for session and token |
toPublicUser | (user) => publicUser | with getUser, for cookie and jwt |
onLogout | (id, ctx) => void | none |
redirect | string, 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:
- The handshake finishes and produces a normalised profile.
onLogin(profile, ctx)stores whoever that is, and returns the id the credential will point at.- For
sessionandtokenthat id goes straight into the credential. Forcookieandjwtthe framework callsgetUser(id)and thentoPublicUser(user), and signs the result instead. - On later requests,
sessionandtokencallgetUser(id, ctx);cookieandjwtjust 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',
},| Key | Meaning | Default |
|---|---|---|
id | OAuth client id | <NAME>_ID from the environment |
secret | OAuth client secret | <NAME>_SECRET from the environment |
scope | 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.
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 elseThey 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:
| Name | Environment | Options beyond scope |
|---|---|---|
cognito | COGNITO_ID, COGNITO_SECRET | domain (required) |
anilist | ANILIST_ID, ANILIST_SECRET | |
apple | APPLE_ID, APPLE_SECRET | teamId, keyId (required), pkcs8PrivateKey (required, a Uint8Array of the .p8 key; option only) |
atlassian | ATLASSIAN_ID, ATLASSIAN_SECRET | |
auth0 | AUTH0_ID, AUTH0_SECRET | domain (required) |
authentik | AUTHENTIK_ID, AUTHENTIK_SECRET | baseURL (required) |
autodesk | AUTODESK_ID, AUTODESK_SECRET | |
battlenet | BATTLENET_ID, BATTLENET_SECRET | |
bitbucket | BITBUCKET_ID, BITBUCKET_SECRET | |
box | BOX_ID, BOX_SECRET | |
bungie | BUNGIE_ID, BUNGIE_SECRET | apiKey |
coinbase | COINBASE_ID, COINBASE_SECRET | |
discord | DISCORD_ID, DISCORD_SECRET | |
donationalerts | DONATIONALERTS_ID, DONATIONALERTS_SECRET | |
dribbble | DRIBBBLE_ID, DRIBBBLE_SECRET | |
dropbox | DROPBOX_ID, DROPBOX_SECRET | |
epicgames | EPICGAMES_ID, EPICGAMES_SECRET | |
etsy | ETSY_ID, ETSY_SECRET | |
facebook | FACEBOOK_ID, FACEBOOK_SECRET | |
figma | FIGMA_ID, FIGMA_SECRET | |
fortytwo | FORTYTWO_ID, FORTYTWO_SECRET | |
gitea | GITEA_ID, GITEA_SECRET | baseURL (required) |
github | GITHUB_ID, GITHUB_SECRET | |
gitlab | GITLAB_ID, GITLAB_SECRET | baseURL (required, https://gitlab.com for the public one) |
google | GOOGLE_ID, GOOGLE_SECRET | |
intuit | INTUIT_ID, INTUIT_SECRET | |
kakao | KAKAO_ID, KAKAO_SECRET | |
keycloak | KEYCLOAK_ID, KEYCLOAK_SECRET | realmURL (required) |
kick | KICK_ID, KICK_SECRET | |
lichess | LICHESS_ID, LICHESS_SECRET | |
line | LINE_ID, LINE_SECRET | |
linear | LINEAR_ID, LINEAR_SECRET | |
linkedin | LINKEDIN_ID, LINKEDIN_SECRET | |
mastodon | MASTODON_ID, MASTODON_SECRET | baseURL (required) |
mercadolibre | MERCADOLIBRE_ID, MERCADOLIBRE_SECRET | |
mercadopago | MERCADOPAGO_ID, MERCADOPAGO_SECRET | |
entra | ENTRA_ID, ENTRA_SECRET | tenant (required, or 'common') |
myanimelist | MYANIMELIST_ID, MYANIMELIST_SECRET | |
naver | NAVER_ID, NAVER_SECRET | |
notion | NOTION_ID, NOTION_SECRET | |
okta | OKTA_ID, OKTA_SECRET | domain (required), authorizationServerId |
osu | OSU_ID, OSU_SECRET | |
patreon | PATREON_ID, PATREON_SECRET | |
polar | POLAR_ID, POLAR_SECRET | |
reddit | REDDIT_ID, REDDIT_SECRET | |
roblox | ROBLOX_ID, ROBLOX_SECRET | |
salesforce | SALESFORCE_ID, SALESFORCE_SECRET | domain (required) |
shikimori | SHIKIMORI_ID, SHIKIMORI_SECRET | |
slack | SLACK_ID, SLACK_SECRET | |
spotify | SPOTIFY_ID, SPOTIFY_SECRET | |
startgg | STARTGG_ID, STARTGG_SECRET | |
strava | STRAVA_ID, STRAVA_SECRET | |
tiktok | TIKTOK_ID, TIKTOK_SECRET | |
tiltify | TILTIFY_ID, TILTIFY_SECRET | |
tumblr | TUMBLR_ID, TUMBLR_SECRET | |
twitch | TWITCH_ID, TWITCH_SECRET | |
twitter | TWITTER_ID, TWITTER_SECRET | |
vk | VK_ID, VK_SECRET | |
withings | WITHINGS_ID, WITHINGS_SECRET | |
workos | WORKOS_ID, WORKOS_SECRET | |
yahoo | YAHOO_ID, YAHOO_SECRET | |
yandex | YANDEX_ID, YANDEX_SECRET | |
zoom | ZOOM_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_SECRETClaims outside the standard set, like group memberships or a namespaced role, arrive in profile.raw.
Strategies
Where the credential rides, and what it holds:
strategy | Carried in | Holds | Per request | What logout invalidates |
|---|---|---|---|---|
'session' | 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 |
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, wHow 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 claimsThat covers most hosted auth, because they all publish their keys the same way:
| Issuer | verify | 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 |
| Key | Type | Required, or default |
|---|---|---|
issuer | issuer URL | required |
audience | string or array of strings | required |
cookie | string | none: reads Authorization: Bearer |
audienceClaim | string or array of strings | 'aud' |
getUser | (id, ctx) => user | none: 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 appThe 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>_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 |
Each row encodes the thing that is easy to get wrong:
- Clerk session tokens carry no
audat all. The authorized party (your frontend origin, likehttps://app.example.com) is inazp, which is what their own backend SDK checks. Settingaudienceto an API identifier, as you would for Auth0, rejects every token. - Supabase uses the literal string
authenticatedas 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>andaudis<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 useclient_id. PassaudienceClaim: ['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
});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 |
| an array | the 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.
| 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 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
| 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 |
onLogin threw | a 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:
| 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 |