Skip to content

Sign In With Auth.js And The OpenID Provider

Auth.js signs users in through the OpenID Provider with a custom OIDC provider. Discovery supplies the endpoints. In the tested version, an oidc provider sent PKCE with no extra setting.


1. Give The App A Real Hostname, Over HTTPS

Section titled “1. Give The App A Real Hostname, Over HTTPS”

So even locally, this app needs https on a name that is not localhost. The test used https://app.guides.test:13443, with Caddy terminating TLS:

{
local_certs
skip_install_trust
admin off
auto_https disable_redirects
}
https://app.guides.test:13443 {
reverse_proxy authjs:13001
}

On your own machine that name needs to resolve to 127.0.0.1, for example through your hosts file. The test instead gave Chromium --host-resolver-rules=MAP *.guides.test 127.0.0.1.


POST /admin/applications. Prefer private_key_jwt, which Auth.js supports:

{
"name": "My Next.js app",
"clientType": "confidential",
"firstParty": true,
"tokenEndpointAuthMethod": "private_key_jwt",
"jwks": { "keys": [{ "kty": "RSA", "n": "...", "e": "AQAB", "kid": "my-app-1", "alg": "RS256", "use": "sig" }] },
"redirectUris": ["https://app.guides.test:13443/api/auth/callback/seamless"],
"postLogoutRedirectUris": ["https://app.guides.test:13443/"],
"grantTypes": ["authorization_code", "refresh_token"]
}

The redirect path is /api/auth/callback/<provider id>. For a shared secret, use client_secret_basic without jwks, after turning on allow_client_secrets.


auth.ts, with private_key_jwt:

import NextAuth from 'next-auth'
const jwk = JSON.parse(process.env.AUTH_SEAMLESS_PRIVATE_JWK ?? '{}')
const key = await crypto.subtle.importKey(
'jwk',
jwk,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign'],
)
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
{
id: 'seamless',
name: 'Seamless',
type: 'oidc',
issuer: process.env.AUTH_SEAMLESS_ISSUER,
clientId: process.env.AUTH_SEAMLESS_ID,
client: { token_endpoint_auth_method: 'private_key_jwt' },
token: { clientPrivateKey: { key, kid: jwk.kid } },
authorization: { params: { scope: 'openid email' } },
},
],
callbacks: {
jwt({ token, account, profile }) {
if (account && profile) {
token.sub = profile.sub ?? token.sub
token.acr = profile.acr
}
return token
},
session({ session, token }) {
session.user.id = token.sub!
return session
},
},
})

token.clientPrivateKey comes from the @auth/core source, not from the Auth.js guides, so check it again when you upgrade.

With a shared secret, drop the key import, client and token, and add clientSecret: process.env.AUTH_SEAMLESS_SECRET.

app/api/auth/[...nextauth]/route.ts:

import { handlers } from '@/auth'
export const { GET, POST } = handlers

Read the session and sign in from a server component:

import { auth, signIn, signOut } from '@/auth'
export default async function Page() {
const session = await auth()
if (!session?.user) {
return (
<form action={async () => { 'use server'; await signIn('seamless') }}>
<button type="submit">Sign in with Seamless</button>
</form>
)
}
return <p>Signed in as {session.user.email}</p>
}

The provider issues no name or picture claim, so session.user.name and image stay empty.


Variable Value
AUTH_URL https://app.guides.test:13443, the app’s public origin
AUTH_SECRET A random secret for Auth.js’s own cookies
AUTH_SEAMLESS_ISSUER The issuer, for example https://auth.guides.localhost:19443
AUTH_SEAMLESS_ID application.id
AUTH_SEAMLESS_PRIVATE_JWK The private JWK as JSON, including its kid. Or AUTH_SEAMLESS_SECRET.
NODE_EXTRA_CA_CERTS Private CA only: the path to the CA file

See Auth.js environment variables and NODE_EXTRA_CA_CERTS. The test ran next build and next start -H 0.0.0.0 -p 13001 in a node:24-alpine container on the recipe’s compose network.


  1. Open the app and choose Sign in with Seamless.
  2. Sign in on the hosted page. A first-party client shows no consent page.
  3. The app shows Signed in as <email>. In the test, session.user.id held the sub, and acr was empty after the first email-code sign-up and phrh after a passkey sign-in on the virtual authenticator.

signOut() ends only the Auth.js session. In the test, the next Sign in with Seamless went straight back to the app without showing the hosted page, because the user’s Seamless session was still live. If your app needs to end that session, send the browser to the provider’s sign-out endpoint as well. That part was not run with Auth.js. It was run in the generic guide.


Each of these was hit while testing. Auth.js’s detail is in the server log, under [auth][cause] and [auth][details].

What you see Log line Cause and fix
Server error, at http://localhost:<port>/api/auth/error?error=Configuration, after the hosted page CallbackRouteError with "error": "invalid_grant", "grant request is invalid" The app runs on http://127.0.0.1. See step 1. /api/auth/providers shows the callback URL it uses.
Registering returns localhost is not a redirect host, for app.guides.localhost None, it is the registration response *.localhost is refused even over https. Use another name, such as one under .test.
Server error, error=Configuration, on choosing sign-in [auth][error] TypeError: fetch failed The app does not trust the issuer’s CA. Set NODE_EXTRA_CA_CERTS.
An error page on /authorize, missing required parameter 'client_id' None AUTH_SEAMLESS_ID is empty.

Registration and shared-secret errors are in the generic guide.


Tested. The configuration above, built and started in production mode, once with private_key_jwt and once with client_secret_basic, with Playwright’s Chromium and a virtual WebAuthn authenticator: a new user registering and a passkey sign-in from a clean browser, ending on a page showing the user. The stack was the local recipe from seamless-auth-oidc-provider, adjusted as described in the generic guide.

Not tested. A browser that trusts the CA normally, a real authenticator, a publicly trusted certificate, a production deployment, next dev, refresh tokens or access token rotation in Auth.js, database sessions, Auth.js middleware, next-auth 4, or other Auth.js integrations.