---
title: OAuth login
description: Register GitHub and Google OAuth apps, set client credentials, and test logins against a local OAuth emulator.
---

Your dashboard's sign-in page shows "Continue with GitHub" and "Continue with Google"
buttons. Each one works only after you register an OAuth app with the provider and put
its credentials in the server's environment. The providers themselves are optional:
leave the variables empty and email and password login keeps working.

The auth server mounts better-auth under `/api/auth` on the API host, so every auth
route, including the OAuth callbacks, lives on `API_URL`.

## Callback URLs

Register one callback URL per provider. Both point at the API host:

| Provider | Callback URL |
| -------- | ------------ |
| GitHub   | `<API_URL>/api/auth/callback/github` |
| Google   | `<API_URL>/api/auth/callback/google` |

With `API_URL=https://api.example.com`, the GitHub callback is:

```
https://api.example.com/api/auth/callback/github
```

Providers compare the callback against their registered values exactly. Scheme, host,
port, and path must match byte for byte, including the absence of a trailing slash.
A redirect that ends at "redirect_uri mismatch" almost always means the registered URL
and `API_URL` disagree on one of those pieces.

## Client credentials

Set all four variables in the server environment:

| Variable | Notes |
| -------- | ----- |
| `GITHUB_CLIENT_ID` | OAuth app client ID |
| `GITHUB_CLIENT_SECRET` | OAuth app client secret |
| `GOOGLE_CLIENT_ID` | OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | OAuth client secret |

The server registers a provider only when both its ID and secret are set. Set one
without the other and the button for that provider still renders, but clicks fail with
an error from the auth server, so keep the pair together. `AUTH_SIGN_UP_ENABLED=false`
keeps social sign-in working while switching off social sign-up: the provider config
disables new-user registration, not logins.

## Trusted origins and the sign-in flow

Sign-in starts from two places: the dashboard on `APP_URL` and public board subdomains
of `APP_ROOT_DOMAIN`. Both must count as trusted origins, or better-auth rejects the
request before the provider ever sees it.

Clicking a social button sends the browser to
`<API_URL>/api/auth/sign-in/social?provider=github&callbackURL=...`. Better-auth checks
the request's `Origin` header and the `callbackURL` against `trustedOrigins` and aborts
with `INVALID_ORIGIN` or `INVALID_CALLBACK_URL` when neither matches. From there it
redirects to the provider's authorize page, and the provider redirects back to the
callback above. A successful exchange creates a 7-day session and redirects the browser
back to the `callbackURL`.

The server builds its default trusted origin list from the same three variables:

- `APP_URL`
- `API_URL`
- `*.<APP_ROOT_DOMAIN>` in production, `*.localhost:3001` in development

The wildcard is what lets users sign in from any board subdomain. The docker-compose
file derives the same default for you:

```
AUTH_TRUSTED_ORIGINS=$APP_URL,$API_URL,*.$APP_ROOT_DOMAIN
```

Set `AUTH_TRUSTED_ORIGINS` yourself and it replaces the defaults entirely, so include
every origin your browsers call the API from. The list is comma-separated. Scheme-less
patterns such as `*.example.com` match both http and https hosts; a pattern like
`https://*.example.com` pins the scheme.

:::warning[Sign-in endpoints demand HTTPS in production]
Production cookies are `Secure` and `SameSite=None`, scoped to `.<APP_ROOT_DOMAIN>`, so
browsers accept them across the dashboard and board subdomains. The API host must serve
HTTPS or the browser drops the session cookie and every login appears to fail.
:::

In development the web server proxies `/api` requests same-origin and sets host-only
cookies per subdomain of `*.localhost`, which is why the dev trusted-origin default is
`*.localhost:3001` rather than a production wildcard.

## Local OAuth emulator

For local development, `GITHUB_EMULATOR_URL` and `GOOGLE_EMULATOR_URL` point the
providers at [vercel-labs/emulate](https://github.com/vercel-labs/emulate) instead of
the real services:

```
GITHUB_EMULATOR_URL=http://localhost:4000
GOOGLE_EMULATOR_URL=http://localhost:4001
```

The repo root's `emulate.config.yaml` defines the emulator's users and OAuth apps. The
registered client credentials and redirect URIs live in that file:

- GitHub app `Iv1.example_client_id` with redirect `http://localhost:3000/api/auth/callback/github`
- Google client `example-client-id.apps.googleusercontent.com` with redirect `http://localhost:3000/api/auth/callback/google`

The emulator provider activates only when the emulator URL, the client ID, and the
client secret are all set, and its credential values must match `emulate.config.yaml`.
With the URL set, the server skips the built-in provider for that service. The
portless URLs `https://github.emulate.localhost` and `https://google.emulate.localhost`
work when your resolver maps `.emulate.localhost`.

## Next steps

<CardGroup cols={2}>
  <Card title="Troubleshooting" href="/self-hosting/troubleshooting">
    Sessions dropping after restart, CORS blocks, and other failure modes.
  </Card>
  <Card title="Environment variables" href="/self-hosting/environment">
    Every variable the server reads, grouped by concern.
  </Card>
</CardGroup>