Better Auth
Better Auth signs users in to a TypeScript web app and keeps its own session for them. Its generic OAuth plugin makes Nauthera the identity provider: the user signs in on Nauthera's hosted pages, Better Auth exchanges the code, verifies the ID token, creates or links its own user from the claims, and from then on the app works with Better Auth's session.
This guide sets it up as strictly as Better Auth allows:
| Measure | How |
|---|---|
| No shared secret | The app authenticates to Nauthera with private_key_jwt: it signs a short-lived assertion with a key that never leaves the app. Nauthera stores only the URL of the public key. |
| Verified ID tokens | Signature, issuer, audience and nonce are checked on every sign-in; discovery without a JWKS stops the provider from starting. |
| Code interception | PKCE with S256, and the redirect URI matched exactly. |
| Verified email only | No session for an address Nauthera has not verified. |
| Tokens at rest | The access token Better Auth stores is encrypted in its database. |
| Session lifetime | Better Auth's session ends no later than Nauthera's. |
The examples use Better Auth 1.7 with Next.js at https://app.example.com and the issuer
https://auth.example.com. Any framework Better Auth supports works the same way; only
the route handlers differ.
Sign-in only
The Spring Boot, Go and
.NET APIs in these guides accept only DPoP-bound access
tokens: a token is bound to a key of the caller's, and every request carries a proof
signed with that key (RFC 9449). Better Auth cannot request DPoP-bound tokens, because it
has no way to add a proof to its token requests. So this app uses Nauthera to sign users
in and does not call those APIs with the user's token. It requests no API scopes, no
offline_access and no refresh token.
If the app has to call a DPoP-only API as the user, the part that does it needs an OAuth
client library with DPoP support, such as
openid-client, which can also sign
private_key_jwt assertions.
1. Create the client key
The app proves its identity to Nauthera with an ES256 key pair. Create it and keep the private key in a Kubernetes Secret:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out client-key.pem
kubectl -n web create secret generic web-app-client-key --from-file=client-key.pem
rm client-key.pemThe key is PKCS#8 PEM, which Better Auth expects. Use this key for client authentication only.
2. Register the client
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
name: web-app
namespace: web
spec:
clientId: web-app
issuer: https://auth.example.com
displayName: Example App
redirectUris:
- https://app.example.com/api/auth/callback/nauthera
postLogoutRedirectUris:
- https://app.example.com/
grantTypes:
- authorization_code
responseTypes:
- code
scopes:
- openid
- profile
- email
tokenEndpointAuthMethod: private_key_jwt
tokenEndpointAuthSigningAlg: ES256
jwksUri: https://app.example.com/oauth/jwks.json
idTokenSignedResponseAlg: ES256
pkcePolicy: required_s256
accessTokenTtlSeconds: 300| Field | Why |
|---|---|
redirectUris | Better Auth's callback, {baseURL}/api/auth/callback/{providerId}. Matched exactly, so the last segment is the providerId in step 4. |
tokenEndpointAuthMethod: private_key_jwt | The token endpoint accepts only an assertion signed by a key at jwksUri. There is no secret, so there is no secret block and no Secret for the operator to write. |
tokenEndpointAuthSigningAlg: ES256 | An assertion signed with any other algorithm is refused, even by a key that is in the JWKS. |
jwksUri | The app's public key, served by the app (step 4). Nauthera fetches it, so it must be public https; a loopback, private or link-local address is refused, and redirects are not followed. |
idTokenSignedResponseAlg: ES256 | ID tokens for this client are always signed with ES256. |
grantTypes, scopes | Only what sign-in needs. email is required: Better Auth identifies its users by email address. |
accessTokenTtlSeconds: 300 | The app does not use the access token beyond sign-in, so it lives five minutes instead of ten. |
kubectl apply -f web-app-client.yaml
kubectl -n web get oidcclient web-app # PHASE Ready, reason Registered3. Give the app its configuration
# Deployment, pod spec
containers:
- name: web
env:
- name: BETTER_AUTH_URL
value: https://app.example.com
- name: BETTER_AUTH_SECRET
valueFrom: { secretKeyRef: { name: web-app-session, key: secret } } # your own, 32+ random bytes
- name: NAUTHERA_ISSUER
value: https://auth.example.com
- name: NAUTHERA_CLIENT_ID
value: web-app
- name: NAUTHERA_CLIENT_KEY_FILE
value: /var/run/secrets/nauthera/client-key.pem
volumeMounts:
- name: client-key
mountPath: /var/run/secrets/nauthera
readOnly: true
volumes:
- name: client-key
secret:
secretName: web-app-client-key
defaultMode: 0400The private key is mounted as a read-only file rather than an environment variable, so it
does not show up in the process environment, in child processes or in crash reports.
BETTER_AUTH_SECRET signs Better Auth's own cookies and encrypts the stored tokens (step
4). It has nothing to do with Nauthera; keep it in a Secret of its own.
4. Configure Better Auth
npm install better-authThe key is read once and used for two things: signing assertions, and publishing the public half with the key ID Nauthera matches assertions against.
// lib/client-key.ts
import { createHash, createPrivateKey, createPublicKey } from "node:crypto";
import { readFileSync } from "node:fs";
export const privateKeyPem = readFileSync(process.env.NAUTHERA_CLIENT_KEY_FILE!, "utf8");
const { kty, crv, x, y } = createPublicKey(createPrivateKey(privateKeyPem)).export({
format: "jwk",
});
// The RFC 7638 thumbprint: the required members in lexical order.
export const kid = createHash("sha256")
.update(JSON.stringify({ crv, kty, x, y }))
.digest("base64url");
export const publicJwks = {
keys: [{ kty, crv, x, y, kid, alg: "ES256", use: "sig" }],
};// app/oauth/jwks.json/route.ts
import { publicJwks } from "@/lib/client-key";
export function GET() {
return Response.json(publicJwks);
}// lib/auth.ts
import { betterAuth } from "better-auth";
import { createPrivateKeyJwtClientAssertionGetter } from "better-auth/oauth2";
import { genericOAuth } from "better-auth/plugins";
import { kid, privateKeyPem } from "@/lib/client-key";
const issuer = process.env.NAUTHERA_ISSUER!;
export const auth = betterAuth({
// database: your adapter, as Better Auth's installation guide shows.
session: {
expiresIn: 60 * 60 * 12, // 12 h, inside Nauthera's 24 h session lifetime
},
account: {
encryptOAuthTokens: true,
},
plugins: [
genericOAuth({
config: [
{
providerId: "nauthera",
name: "Nauthera",
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
clientId: process.env.NAUTHERA_CLIENT_ID!,
tokenEndpointAuth: {
method: "private_key_jwt",
getClientAssertion: createPrivateKeyJwtClientAssertionGetter({
privateKeyPem,
kid,
algorithm: "ES256",
expiresIn: 60,
}),
},
scopes: ["openid", "profile", "email"],
requireIdTokenVerification: true,
requireEmailVerification: true,
postLogoutRedirectURI: "/",
mapProfileToUser: (profile) => ({
name: String(profile.name ?? profile.preferred_username ?? profile.email),
}),
},
],
}),
],
});What each setting does:
| Setting | Effect |
|---|---|
tokenEndpointAuth | Every code exchange carries a fresh assertion: iss and sub are the client ID, aud is the token endpoint, jti is unique (Nauthera accepts each one once), and it expires after 60 seconds. |
requireIdTokenVerification | The provider does not start unless discovery names the issuer and a JWKS, so the ID token is always verified (signature, iss, aud, exp, nonce) and never just decoded. |
requireEmailVerification | No session for a user whose email_verified is false. Nauthera sets it only once the user followed the verification link or an administrator vouched for the address. |
encryptOAuthTokens | The access token (and a refresh token, if one were issued) on the account row is encrypted with a key derived from BETTER_AUTH_SECRET. The ID token is stored as is, because sign-out sends it to Nauthera as id_token_hint. |
session.expiresIn | Better Auth's session ends no later than Nauthera's auth.session.absolute_ttl (24 h by default). Better Auth is not told when the Nauthera session ends early, so keep this short. |
| PKCE | On by default (pkce); leave it on. Nauthera refuses an authorization request without an S256 code challenge, confidential clients included. |
mapProfileToUser | Fills Better Auth's required name. Nauthera sends name only when the user has one. |
For an organization other than the main one, the issuer is
https://auth.example.com/orgs/acme; the discovery URL and the token endpoint follow
from it.
Mount the handler. For the Next.js App Router:
// app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);Then create Better Auth's tables with its CLI (npx @better-auth/cli migrate, or
generate for an ORM).
5. Sign the user in
The generic OAuth plugin registers Nauthera as an ordinary social provider, so the client needs no extra plugin:
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();<button
onClick={() =>
authClient.signIn.social({ provider: "nauthera", callbackURL: "/dashboard" })
}
>
Sign in
</button>The browser goes to Nauthera, the user signs in (with MFA if they have it), and Better
Auth receives the code on its callback, checks state, redeems the code with the PKCE
verifier and a signed assertion, verifies the ID token and creates its session.
authClient.useSession() and auth.api.getSession({ headers }) read that session as
usual.
Clients registered through the operator count as approved by an administrator, so users
are not asked for consent. The Nauthera user's sub is stored as the accountId on
Better Auth's account row, which is the stable key to join on. The email address can
change.
6. Sign out
await authClient.signOut();This ends Better Auth's session and then, because the provider has an end-session
endpoint in its discovery document, sends the browser to Nauthera's /end_session with
the user's ID token as id_token_hint, the client_id and postLogoutRedirectURI
resolved against BETTER_AUTH_URL. Nauthera ends its own session at once and returns to
https://app.example.com/, which must be one of the client's postLogoutRedirectUris.
If the ID token has expired, Nauthera asks the user to confirm first. See
Connect an Application for what ending
the session does to the user's other applications.
Rotating the client key
Nauthera reads the keys at jwksUri and accepts an assertion signed by any of them, so a
rotation needs no change to the OIDCClient:
- Generate a second key and publish both public keys at
jwksUri. - Wait at least ten minutes, the time Nauthera keeps a client's key set, then switch signing to the new key.
- Remove the old public key from
jwksUri, and delete the old private key.
Local development
The Quick Start server cannot fetch a JWKS from your machine,
because http://localhost and private addresses are refused. Register the local client
with a generated secret instead, with nauthera-cli and the redirect URI
http://localhost:3000/api/auth/callback/nauthera, and use it only for development:
tokenEndpointAuth: { method: "client_secret_basic" },
clientSecret: process.env.NAUTHERA_CLIENT_SECRET!,Make Node trust the local certificate:
NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" npm run devNot supported here
- FAPI mode. An organization in FAPI mode requires pushed authorization requests (PAR), which Better Auth does not send. Use a library with PAR support for clients of such an organization.
- Back-channel logout. Better Auth cannot receive Nauthera's logout tokens, so a
session ended on Nauthera's side, by an administrator for example, leaves Better Auth's
session running until
session.expiresIn.
Troubleshooting
| Symptom | Cause |
|---|---|
| Nauthera shows an error page instead of the sign-in form | The redirect URI Better Auth sent is not registered exactly. Compare BETTER_AUTH_URL and the providerId with spec.redirectUris. |
invalid_client on the callback | Nauthera could not verify the assertion: jwksUri is not reachable from the server, does not hold the key with this kid, or the key is not ES256. |
The OIDCClient is Refused naming jwks_uri | The URL is not public https, or it carries a fragment. |
invalid_request mentioning PKCE | pkce was set to false. |
invalid_scope | A requested scope is not in the client's spec.scopes. |
The callback ends with ?error=email_not_verified | The user has not verified their address in Nauthera. |
| Sign-out lands on Nauthera's own logout page | postLogoutRedirectURI, resolved against BETTER_AUTH_URL, is not in spec.postLogoutRedirectUris. |