go-auth
Guides

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

CodeStatusCause
provider_not_found404{provider} doesn't match any registered WithProvider name
internal_error500Failed 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.txt

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

CodeStatusCause
already_linked409This exact provider account is already linked to your account
provider_already_linked409This 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

CodeStatusCause
cannot_unlink_last_provider400Account has no password and this is the only linked provider — set a password first (Security → Set a password)
user_not_found404Session's user no longer exists
internal_error500Database 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.txt

Programmatic (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.txt

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


Next

  • Providers — registering GitHub/Google and the initial sign-in flow
  • Security — setting a password so a provider can be safely unlinked
  • Routes — full param reference

On this page