Self-Hosted SSO: Wiring authentik to Next.js 16 and Auth.js v5, Including the Logout That Does Not Log Out
Putting an identity provider you own in front of your own applications is a weekend of work and a decade of consequences. Here is the whole mechanism — the discovery document, the id_token, the encrypted session cookie — and the single logout that Auth.js cannot perform at all.
The sixth login form is the one that breaks you. Every application you run has accumulated its own users table, its own password reset email, its own half-considered opinion about session length. None of them share anything. Offboarding somebody means remembering all six, and you will not remember all six.
So you put an identity provider in front of them. That part is genuinely a weekend of work. What follows is the other part: what the protocol is actually doing, which of it your library implements, and the one thing that looks finished on the day you ship it and is not finished at all.
Everything below was checked against authentik 2026.8.2 and [email protected] running on Next.js 16.2.6 — a real instance, a real authorisation code flow, and the library's own source. Where I quote a number, I measured it.
What you are actually buying
authentik is a Django application, a Go/Rust proxy and a worker, all in front of Postgres. You run four containers and you get an OpenID Provider. As of 2026.8 it is formally OpenID Certified™ for the Basic, Implicit, Hybrid, Config and Form Post OP profiles, and — remember this for later — for all three logout profiles: RP-Initiated, Front-Channel and Back-Channel.
That certification matters because it changes where the bugs live. The IdP is now the boring, specification-conformant half of the system. Everything that goes wrong from here goes wrong on your side of the wire.
The provider, and the trap in its defaults
An authentik provider is the OIDC configuration; an application is the thing users see, bound to a provider. Create the provider, bind an application, and the discovery document appears at a per-application path:
curl -s http://localhost:9000/application/o/altixcode-admin/.well-known/openid-configuration{
"issuer": "http://localhost:9000/application/o/altixcode-admin/",
"authorization_endpoint": "http://localhost:9000/application/o/authorize/",
"token_endpoint": "http://localhost:9000/application/o/token/",
"userinfo_endpoint": "http://localhost:9000/application/o/userinfo/",
"end_session_endpoint": "http://localhost:9000/application/o/altixcode-admin/end-session/",
"jwks_uri": "http://localhost:9000/application/o/altixcode-admin/jwks/",
"backchannel_logout_supported": true,
"backchannel_logout_session_supported": true,
"frontchannel_logout_supported": true,
"id_token_signing_alg_values_supported": [
"RS256"
],
...
}Two things in there will cost you an afternoon each.
The issuer has a trailing slash. http://localhost:9000/application/o/altixcode-admin/ — and issuer comparison in OIDC is exact string equality, not URL equivalence. Drop the slash in your client config and every login fails token validation with an error that talks about the issuer rather than about the slash.
Only the endpoints are shared; the discovery, JWKS and end-session URLs are per application. The authorise and token endpoints are global, which reads as inconsistent until you realise the provider is resolved from client_id.
The trap is one level further down. Creating a provider through the API in 2026.8 gives you this:
curl -s -H "$H" "$AK/providers/oauth2/1/" | grep grant_typesgrant_types = []Empty. Not "empty meaning all" — empty meaning none. Every authorisation attempt against that provider dies with a redirect carrying error=invalid_request&error_description=The%20request%20is%20otherwise%20malformed, and the only place the real reason appears is the server log:
{"event": "Invalid grant_type for provider", "grant_type": "authorization_code",
"level": "warning", "logger": "authentik.providers.oauth2.views.authorize"}The web UI fills this field in for you. The API does not. If you are provisioning providers as code — and you should be, that is half the point of self-hosting — set grant_types explicitly or nothing works and the client-visible error tells you nothing.
Wiring it to Auth.js
Auth.js ships an Authentik provider, but writing the OIDC config by hand is four lines longer and shows you what is actually being negotiated:
import NextAuth from "next-auth";
import type { OIDCConfig } from "next-auth/providers";
interface AuthentikProfile {
sub: string;
email: string;
name: string;
preferred_username: string;
groups: string[];
}
const authentik: OIDCConfig<AuthentikProfile> = {
id: "authentik",
name: "authentik",
type: "oidc",
// Exactly as the discovery document reports it — trailing slash included.
issuer: process.env.AUTHENTIK_ISSUER,
clientId: process.env.AUTHENTIK_CLIENT_ID,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET,
authorization: { params: { scope: "openid email profile" } },
checks: ["pkce", "state", "nonce"],
profile: (p) => ({
id: p.sub,
email: p.email,
name: p.name,
// Deliberately not spreading the profile. See § The cookie.
groups: p.groups,
}),
};
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
session: { strategy: "jwt", maxAge: 60 * 60 * 8 },
providers: [authentik],
});checks is the part worth being deliberate about. pkce binds the authorisation code to the client that requested it, state defends the callback against CSRF, and nonce binds the id_token to this particular authorisation request. All three are cheap. Auth.js defaults to including them for OIDC providers; naming them means a future refactor cannot quietly drop one.
One non-obvious flag: OIDCConfig carries idToken?: boolean, defaulting to true, and the library's own comment explains the consequence — "If set to false, the userinfo_endpoint will be fetched for the user data." So by default your profile() callback receives the id_token claims, not the userinfo response. If a claim is missing, the question is not "why doesn't userinfo have it" but "why isn't it in the id_token".
What is actually in the token
Here is a real id_token from the flow above, decoded. This is a complete authorisation-code-plus-PKCE exchange against the running instance, not an illustration:
header: {"alg": "RS256", "kid": "-STEeh-QtQvGt8wz…", "typ": "JWT"}
claims: {
"iss": "http://localhost:9000/application/o/altixcode-admin/",
"sub": "4e8f5e3c111eb168c1157881bb99f8c2bdc805cf5324f0d72c49dd045bae6179",
"aud": "altixcode-admin",
"exp": 1789558786,
"iat": 1789555186,
"auth_time": 1789555186,
"acr": "goauthentik.io/providers/oauth2/default",
"amr": ["pwd"],
"nonce": "fIcjow3g_C3Uz_vKFHQD8A",
"sid": "784797920b6e4acc15e73f46651f1452524506d9549d5cabbe4f2629e4b9e972",
"email": "[email protected]",
"email_verified": false,
"name": "authentik Default Admin",
"preferred_username": "akadmin",
"groups": ["authentik Admins"],
"picture": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcv…"
}
scope granted: email profile openid | expires_in: 3600 | refresh_token present: FalseFour observations, each of which is a decision you now have to make.
groups arrives inside the profile scope. There is no groups scope in authentik's defaults. The stock profile property mapping is a Python expression ending in "groups": [group.name for group in request.user.groups.all()]. You do not need a custom mapping to do role-based access control — you need to not strip profile.
sub is a hash, not a username. The provider's sub_mode defaults to hashed_user_id. That is the right default — it is an opaque, stable, per-provider identifier — but it means sub is useless for support tickets. Key your local user rows on it anyway and store preferred_username alongside for humans.
No refresh token, despite a one-hour access token. Refresh tokens require the offline_access scope. Ask for it explicitly or accept that you have an eight-hour session backed by a one-hour token you cannot renew.
picture is a 626-byte base64 SVG. authentik generates an avatar and inlines it into the token as a data URI. Hold that thought.
The cookie, measured
Auth.js with strategy: "jwt" does not store a session server-side. It puts the whole thing in a cookie — and "JWT" undersells what it does. From the library's own source:
const alg = "dir";
const enc = "A256CBC-HS512";It is a JWE, not a JWS. The payload is encrypted, not merely signed, with a key derived from AUTH_SECRET via HKDF-SHA256 salted with the cookie name. Five segments, not three, and opaque to the browser. Running the real encode:
import { encode, decode } from "@auth/core/jwt";
const secret = "an-example-secret-not-a-real-one";
const salt = "authjs.session-token";
const token = {
name: "Ata Mohammadi", email: "[email protected]", sub: "9f2c…",
groups: ["authentik Admins", "altixcode-editors", "altixcode-admins"],
};
const jwe = await encode({ token, secret, salt, maxAge: 60 * 60 * 8 });
console.log("JOSE header :", JSON.parse(Buffer.from(jwe.split(".")[0], "base64url").toString()));
console.log("segments :", jwe.split(".").length);
console.log("cookie bytes:", jwe.length);
console.log("round-trip :", (await decode({ token: jwe, secret, salt }))?.groups);JOSE header : { alg: 'dir', enc: 'A256CBC-HS512', kid: 'SC0EzB96j8jnpI7Ng4VTMUIX…' }
segments : 5
cookie bytes: 542
round-trip : [ 'authentik Admins', 'altixcode-editors', 'altixcode-admins' ]That the payload is encrypted is genuinely useful: group membership in a session cookie is not something you want readable by anything that can run JavaScript on your domain. But it also means the cookie is bigger than the data, and browsers cap cookies at 4096 bytes. Auth.js handles the overflow by splitting into authjs.session-token.0, .1, .2 — with CHUNK_SIZE = 4096 - 160, where 160 is a hardcoded estimate of the cookie's non-payload overhead.
Now feed it that real token. Passing the claims through unmodified against dropping one field:
id_token picture claim : 626 bytes
session cookie, as-is : 1950 bytes -> 1 cookie(s)
session cookie, trimmed: 1097 bytes -> 1 cookie(s)Deleting one decorative avatar nearly halves the session cookie, which is then attached to every single request to your origin, including every static asset that is not on a separate domain.
I want to be honest about the scale of this, because the usual telling of it is scaremongering. Chunking is real but it is far away:
1 groups -> 1353 bytes, 1 cookie(s)
10 groups -> 1609 bytes, 1 cookie(s)
25 groups -> 2014 bytes, 1 cookie(s)
50 groups -> 2718 bytes, 1 cookie(s)
75 groups -> 3422 bytes, 1 cookie(s)
100 groups -> 4126 bytes, 2 cookie(s)You need about a hundred group memberships before the cookie splits. Almost nobody has that. The real cost is not chunking — it is the two kilobytes of encrypted payload riding on every request for the lifetime of the session, in exchange for an avatar nobody asked for. Spread the profile into the token and you pay it forever; pick fields explicitly and you do not.
The proxy, on both sides
Auth.js constructs callback URLs from the request's Host header, and refuses to trust it unless you say so:
When deploying your application behind a reverse proxy, you'll need to set
AUTH_TRUST_HOSTequal totrue.
It infers this automatically for exactly two environments — VERCEL and CF_PAGES. Self-host and you are neither, so the first symptom is UntrustedHost, and the second, once you have set it, is a redirect URI built with http:// when your site is https://. Traefik must be forwarding X-Forwarded-Proto for the scheme to survive, and authentik must agree that the redirect URI matches exactly — its default matching mode is strict string comparison.
2026.8 tightened the other end of the same rope: forwarded headers are now honoured only from trusted proxies. If authentik sits behind your reverse proxy and you have not told it which upstream to trust, it stops believing X-Forwarded-For and starts logging your proxy's IP as the source of every login. Not a failure — a silently useless audit log, which is worse.
There is one piece of good news here, and it invalidates most of the tutorials you will find. Next.js 16 renamed middleware.ts to proxy.ts and, per the shipped documentation, "Proxy uses the Node.js runtime." The elaborate auth.config.ts split that every Auth.js v5 guide teaches — a stripped, database-free config so it could survive the Edge runtime — is no longer necessary. What is still true, and the Next.js docs are blunt about it, is that proxy is for optimistic checks only:
While Proxy can be useful for initial checks, it should not be your only line of defense in protecting your data.
Read the cookie in proxy.ts to bounce logged-out users to the login page. Do the real authorisation next to the data.
The logout that does not log out
This is the part that matters, and the reason this article exists.
signOut() looks finished. The user clicks it, the session disappears, they land back on the login page. Here is what it actually does, in full, from @auth/core/lib/actions/signout.js:
if (session.strategy === "jwt") {
const token = await jwt.decode({ ...jwt, token: sessionToken, salt });
await events.signOut?.({ token });
}
...
cookies.push(...sessionStore.clean());It decodes the cookie, fires an event, and deletes the cookie. That is the whole function. It never contacts authentik.
The authentik session is still open. Click "sign in" again and the authorisation endpoint sees a valid IdP session cookie and returns a fresh code without any prompt at all — which is precisely what happened in the transcript earlier in this article: one authorise request, one 302, one code, no password. On a shared machine, "log out" means the next person is one click from being you.
The fix is standardised and authentik implements it. The discovery document advertises end_session_endpoint, and the token carries an sid specifically so sessions can be correlated across relying parties. Auth.js implements neither side:
grep -rl "end_session\|logout_token\|backchannel" node_modules/@auth/core/(no matches)And the route table has nowhere to put it. The complete set of actions the library will answer:
export type AuthAction = "callback" | "csrf" | "error" | "providers" | "session"
| "signin" | "signout" | "verify-request" | "webauthn-options"So: authentik is certified for RP-Initiated, Front-Channel and Back-Channel logout. Auth.js can perform none of the three. An administrator who disables an account in authentik has not ended that person's sessions in your applications, and will reasonably assume they have.
Two mitigations, neither of them complete:
- RP-initiated logout by hand. After
signOut(), redirect the browser to theend_session_endpointwithid_token_hint. Perhaps fifteen lines. It closes the shared-machine hole, which is the common one. - Short sessions plus a revocation check. Back-channel logout needs a route to receive the
logout_token, and you would be writing it yourself. The pragmatic substitute is to stop trusting the cookie's age. The production config on the sites you are reading this on does exactly that for password changes — thejwtcallback re-checks a server-side timestamp and returnsnullto kill the token:
async jwt({ token }) {
const record = await db.user.findUnique({
where: { id: String(token.uid) },
select: { revokedAt: true },
});
// Returning null invalidates the session — every tab, not just this one.
if (!record) return null;
if (typeof token.iat === "number" && record.revokedAt &&
token.iat * 1000 < record.revokedAt.getTime()) return null;
return token;
}That reintroduces a database read on session checks, which is most of what stateless JWT sessions were meant to avoid. That is the actual trade, stated plainly: a JWT session is not revocable, and single logout is the feature that makes you notice.
The uncomfortable part
Auth.js has been 5.0.0-beta for over three years. As I write this, npm view next-auth dist-tags reports latest: 4.24.15 and beta: 5.0.0-beta.32 — v5, the version every current tutorial teaches and the one these two sites run in production, has never had a stable release. The documentation now opens with a banner reading "The Auth.js project is now part of Better Auth", linking a migration guide, while the install command it gives you is still npm install next-auth@beta.
None of that means it is broken. It is a widely deployed, well-built library, and the missing logout support is a gap rather than a defect. But if you are choosing today, choose knowing that the thing you are wiring your identity layer to is a permanent beta in maintenance mode, and that the feature you will want on the day somebody leaves the company is the one it does not have.
The IdP is not the risky half of this system. The client library is.
The compressed version
- Set
grant_typesexplicitly when creating authentik providers via the API — the default is empty and denies everything, with a useless client-side error. - Copy the
issuerfrom the discovery document verbatim, trailing slash included. groupsrides inside the standardprofilescope; there is no separate groups scope.- Auth.js session cookies are JWE, not JWS — encrypted with
dir/A256CBC-HS512, chunked above ~3.9 KB. Pick profile fields explicitly; authentik'spictureclaim alone is 626 bytes on every request. - Self-hosted means
AUTH_TRUST_HOST=true,X-Forwarded-Protofrom your proxy, and — new in authentik 2026.8 — a trusted-proxy list on the IdP or your audit log records the proxy's IP. - Next.js 16 renamed middleware to
proxy.tsand runs it on Node, so the old edge-safe split config is obsolete. Use it for optimistic redirects only. signOut()deletes a cookie and nothing else. Add RP-initiated logout yourself, and if revocation has to be real, accept a lookup in thejwtcallback.
Sources
- authentik 2026.8 release notes — OpenID certification, token exchange, trusted-proxy change
- authentik OAuth 2.0 provider documentation — endpoints, redirect URI matching
- Auth.js deployment guide —
AUTH_SECRET,AUTH_TRUST_HOST, auto-trusted hosts - Next.js 16.2.6 shipped documentation,
01-app/01-getting-started/16-proxy.mdand01-app/02-guides/authentication.md @auth/core5.0.0-beta.32 source:jwt.js,lib/utils/cookie.js,lib/actions/signout.js,providers/oauth.d.ts