# Add sign-in to a TypeScript app with Better Auth

> Sign users in to a TypeScript web app through Nauthera with Better Auth, using private_key_jwt and verifying every ID token, registered by an OIDCClient.

This guide adds sign-in through Nauthera to a TypeScript web app with Better Auth. You
need a Nauthera issuer with its [operator](https://www.nauthera.io/docs/v1/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](https://www.better-auth.com) 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 [Go](https://www.nauthera.io/docs/v1/guides/go) and [.NET](https://www.nauthera.io/docs/v1/guides/dotnet) 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](https://github.com/panva/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:

```bash
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

```yaml
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
```

| 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. |

```bash
kubectl apply -f web-app-client.yaml
kubectl -n web get oidcclient web-app      # PHASE Ready, reason Registered
```

## 3. Give the app its configuration

```yaml
# 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

```bash
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.

```ts
// 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" }],
};
```

```ts
// app/oauth/jwks.json/route.ts
import { publicJwks } from "@/lib/client-key";

export function GET() {
  return Response.json(publicJwks);
}
```

```ts
// 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, an administrator vouched for the address, or an identity provider trusted for email (`trustEmail`) reported it verified. |
| `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:

```ts
// 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:

```ts
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient();
```

```tsx
<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

```ts
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](https://www.nauthera.io/docs/v1/integration#10-sign-the-user-out) 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](https://www.nauthera.io/docs/v1/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](https://www.nauthera.io/docs/v1/quick-start#4-register-your-own-client), the redirect URI
`http://localhost:3000/api/auth/callback/nauthera`, `tokenEndpointAuthMethod:
client_secret_basic` and `secret.generate: true`, and use it only for development:

```ts
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](https://www.nauthera.io/docs/v1/quick-start#1-start-the-stack):

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

## Not supported here

- **FAPI mode.** An organization in [FAPI mode](https://www.nauthera.io/docs/v1/organizations#fapi-mode-and-fips-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`. |
