Skip to content

The v1 docs follow nauthera-server's main branch and its 0.x releases on the way to 1.0, so fields and APIs can still change.

v1

Add sign-in to a TypeScript app with Better Auth

Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated

This guide adds sign-in through Nauthera to a TypeScript web app with Better Auth. You need a Nauthera issuer with its operator running in the cluster, and Better Auth with a framework it supports (the examples use Better Auth 1.7 with Next.js). At the end, users sign in on Nauthera's hosted pages, the app proves who it is with a private key instead of a secret and checks every ID token, and Better Auth keeps the session. The main limit: Better Auth cannot request DPoP-bound tokens, so the app signs users in but does not call DPoP-only APIs as the user.

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:

MeasureHow
No shared secretThe 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 tokensSignature, issuer, audience and nonce are checked on every sign-in; discovery without a JWKS stops the provider from starting.
Code interceptionPKCE with S256, and the redirect URI matched exactly.
Verified email onlyNo session for an address Nauthera has not verified.
Tokens at restThe access token Better Auth stores is encrypted in its database.
Session lifetimeBetter 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 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.pem

The 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          # a namespace the organization admits
spec:
  clientId: web-app
  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
FieldWhy
redirectUrisBetter Auth's callback, {baseURL}/api/auth/callback/{providerId}. Matched exactly, so the last segment is the providerId in step 4.
tokenEndpointAuthMethod: private_key_jwtThe 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: ES256An assertion signed with any other algorithm is refused, even by a key that is in the JWKS.
jwksUriThe 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: ES256ID tokens for this client are always signed with ES256.
grantTypes, scopesOnly what sign-in needs. email is required: Better Auth identifies its users by email address.
accessTokenTtlSeconds: 300The 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 Registered

3. 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: 0400

The 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-auth

The 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:

SettingEffect
tokenEndpointAuthEvery 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.
requireIdTokenVerificationThe 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.
requireEmailVerificationNo session for a user whose email_verified is false. Nauthera sets it only once the user followed the verification link, an administrator vouched for the address, or an identity provider trusted for email (trustEmail) reported it verified.
encryptOAuthTokensThe 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.expiresInBetter 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.
PKCEOn by default (pkce); leave it on. Nauthera refuses an authorization request without an S256 code challenge, confidential clients included.
mapProfileToUserFills 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:

  1. Generate a second key and publish both public keys at jwksUri.
  2. Wait a minute, then switch signing to the new key. Nauthera fetches the key set again, at most once a minute, when an assertion names a key it does not hold.
  3. 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 an OIDCClient as in Quick Start step 4, the redirect URI http://localhost:3000/api/auth/callback/nauthera, tokenEndpointAuthMethod: client_secret_basic and secret.generate: true, and use it only for development:

tokenEndpointAuth: { method: "client_secret_basic" },
clientSecret: process.env.NAUTHERA_CLIENT_SECRET!,

Make Node trust the chart's local CA, nauthera-local-ca.crt from step 1:

NODE_EXTRA_CA_CERTS="$PWD/nauthera-local-ca.crt" npm run dev

Not 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

SymptomCause
Nauthera shows an error page instead of the sign-in formThe redirect URI Better Auth sent is not registered exactly. Compare BETTER_AUTH_URL and the providerId with spec.redirectUris.
invalid_client on the callbackNauthera 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_uriThe URL is not public https, or it carries a fragment.
invalid_request mentioning PKCEpkce was set to false.
invalid_scopeA requested scope is not in the client's spec.scopes.
The callback ends with ?error=email_not_verifiedThe user has not verified their address in Nauthera.
Sign-out lands on Nauthera's own logout pagepostLogoutRedirectURI, resolved against BETTER_AUTH_URL, is not in spec.postLogoutRedirectUris.