OAuth linking
Connecting and disconnecting an OAuth provider on an already-logged-in account, and listing which ones are connected.
OAuth linking
The initial sign-up/sign-in redirect flow (GET /auth/oauth/{provider} → provider approval → callback) is covered per-provider on the Providers pages — it's identical regardless of which provider you use. This page covers the three endpoints that only matter for an already-authenticated user: linking an additional provider to their account, unlinking one, and listing what's currently connected.
Requires AuthenticationConfig.OAuth: true and at least one WithProvider(...) call — see Authentication → Configuration. Disabling authentication OAuth unmounts its route group; enabling it without a provider fails startup.
The curl mutation examples use jq to read the mandatory CSRF bootstrap JSON. Keep the same cookie jar for bootstrap and the following request; the bootstrap is repeated after authentication changes.
OAuth defaults to the app context. To initiate an admin flow, send X-GoAuth-Session: admin on the initiation request. The verified state carries this context through the provider callback, which does not need the header. Subsequent API and 2FA requests still need the admin selector. Admin OAuth login requires an existing linked protected-admin identity; it cannot create a new admin account. The configured AppConfig.BaseURL remains the callback redirect destination. The bundled panel currently uses password/email-2FA login.
Frontend client setup
Admin sign-in through a linked provider stops for email 2FA by default. The
landing URL includes step=two_factor, challengeId, and expiresAt.
Show the code-entry screen and submit the challenge and email code to
POST /auth/2fa/verify in the same browser; the callback has already set the
HttpOnly binding cookie. No session exists until verification succeeds. See
OAuth callback outcomes.
The Client examples below call the same apiRequest(baseUrl, method, path, body) helper used throughout these guides — see Client → Setup. The named-method wrapper for everything on this page lives on Client → OAuth.
Linking a provider
Start a link — POST /auth/oauth/{provider}/link
Auth required. Returns the provider's authorization URL, same shape as the login-initiate endpoint, and sets a browser state cookie. Redirect that same browser to the URL. The state token stores the current user's ID and initiating session token hash; the callback links only if that exact session is still live.
Response (200 OK): { "url": "https://github.com/login/oauth/authorize?..." }
Errors
| Code | Status | Cause |
|---|---|---|
provider_not_found | 404 | {provider} doesn't match any registered WithProvider name |
internal_error | 500 | Failed to generate or store the PKCE state |
curl
CSRF=$(curl -fsS https://api.myapp.com/auth/csrf-token -b cookies.txt -c cookies.txt | jq -r .token)
curl -X POST https://api.myapp.com/auth/oauth/github/link \
-H "X-CSRF-Token: $CSRF" \
-c cookies.txt \
-H "Origin: https://myapp.com" \
-b cookies.txtThe response is JSON, not a redirect — your frontend reads url and navigates the browser there itself (window.location.href = url), the same way the login-initiate flow works.
Programmatic (Go)
Import github.com/nazimdjebloun/go-auth/api for operation inputs.
flow, err := auth.Services().OAuth.InitiateLink(ctx, api.OAuthLinkInput{
Provider: "github", UserID: userID, SessionTokenHash: session.TokenHash,
})
if err != nil {
// wraps a *domain.AuthError — provider_not_found if the name is wrong
}
// Bind flow.State to the initiating browser as the HTTP handler does,
// then redirect the browser to flow.URL.Client
const { url } = await apiRequest(API_BASE, "POST", "/auth/oauth/github/link");
window.location.href = url;See Client → Link provider for the wrapper and connect button component.
The callback — same route as login, different outcome
GET/POST /auth/oauth/{provider}/callback is the same route used for login (see Providers) — the handler tells link and login apart by whether the OAuth state token has a userID attached, not by a different URL. On success it redirects to {BaseURL}/auth/callback same as always; on failure, {BaseURL}/auth/callback?error={code}&provider={provider}.
Errors (link-specific — on top of the general OAuth callback errors in Error Handling)
| Code | Status | Cause |
|---|---|---|
already_linked | 409 | This exact provider account is already linked to your account |
provider_already_linked | 409 | This provider account belongs to a different user already — can't be linked to two accounts |
There's no separate curl/Go/Client section for the callback itself — it's a browser redirect the provider calls, not something your frontend calls directly. Read error/provider off {BaseURL}/auth/callback's query string to show a message.
Logging in with an email that already has a password account
This isn't a linking flow at all — it's what happens on a plain sign-in
(GET /auth/oauth/{provider} with no existing session) when the email the
provider returns already belongs to an email/password account. go-auth never
auto-links the two on your behalf: the callback fails with
email_already_exists (409) rather than silently attaching the OAuth
identity to a possibly-different person's account. The user needs to log in
with their password first, then use Start a link
above to connect the provider explicitly. There's no special UI for this
built in — show the error and point the user at your login form.
Unlinking a provider
Unlink — POST /auth/oauth/{provider}/unlink
Auth required. Blocked if this is the account's only way to log in — unlinking must never leave an account with no password and no remaining provider. Unlink removes all identities for the selected provider. Two linked Google identities do not count as a surviving method when unlinking Google: a passwordless account must retain an identity from a different provider.
Response: { "message": "Provider unlinked" }
Errors
| Code | Status | Cause |
|---|---|---|
cannot_unlink_last_provider | 400 | Account has no password and this is the only linked provider — set a password first (Security → Set a password) |
user_not_found | 404 | Session's user no longer exists |
internal_error | 500 | Database failure |
curl
CSRF=$(curl -fsS https://api.myapp.com/auth/csrf-token -b cookies.txt -c cookies.txt | jq -r .token)
curl -X POST https://api.myapp.com/auth/oauth/github/unlink \
-H "X-CSRF-Token: $CSRF" \
-c cookies.txt \
-H "Origin: https://myapp.com" \
-b cookies.txtProgrammatic (Go)
err := auth.Services().OAuth.Unlink(ctx, api.OAuthUnlinkInput{UserID: userID, Provider: "github"})Client
try {
await apiRequest(API_BASE, "POST", "/auth/oauth/github/unlink");
} catch (err) {
// cannot_unlink_last_provider — prompt to set a password first
}See Client → Unlink provider for the wrapper and unlink error handling.
Listing connected providers
List connected — GET /auth/oauth/providers
Auth required. Only ever returns the safe, display-oriented fields — access and refresh tokens are never serialized here, regardless of whether Encryptor is configured.
Response (200 OK)
{
"providers": [
{
"provider": "github",
"email": "ada@example.com",
"name": "Ada Lovelace",
"avatarUrl": "https://avatars.githubusercontent.com/...",
"createdAt": "2026-08-09T12:00:00Z"
}
]
}curl
curl https://api.myapp.com/auth/oauth/providers \
-H "Origin: https://myapp.com" \
-b cookies.txtProgrammatic (Go)
accounts, err := auth.Services().OAuth.ListConnected(ctx, userID)
// accounts[i].AccessToken / RefreshToken ARE populated here — this is the
// internal domain type, not the handler's filtered response shape above.
// Never send this straight back to a client.Client
const { providers } = await apiRequest(API_BASE, "GET", "/auth/oauth/providers");See Client → List connected providers for the wrapper and provider list usage.