semaphoreui/semaphore · error
You must be signed in to link an external account.
Error message
You must be signed in to link an external account.
What it means
In account-linking mode (?link=...), after passing the POST-method check the handler loads the user's session via getSession(r) and requires it to exist and be verified (session.IsVerified()). If there is no valid verified session, it returns HTTP 401 'You must be signed in to link an external account.' Linking an external identity requires an already-authenticated Semaphore account.
Solutions
- Sign in to Semaphore (completing any MFA/verification) before initiating account linking
- Ensure the linking POST request carries the session cookie (credentials: 'include' in cross-origin fetch)
- Re-authenticate if the session expired, then retry the linking flow
- Use the same browser/site context so SameSite cookie rules do not strip the session cookie
Example fix
// before: cookie not sent, 401
fetch('/api/auth/oidc/github?link=true', { method: 'POST' })
// after
fetch('/api/auth/oidc/github?link=true', { method: 'POST', credentials: 'include' }) Defensive patterns
Strategy: validation
Validate before calling
// check the user has a verified session before offering the link action:
const res = await fetch('/api/session/status', { credentials: 'include' })
if (!res.ok) { /* redirect to login first; linking requires a verified session */ } Try / catch
const resp = await fetch(`/api/auth/oidc/${pid}?link=true`, { method: 'POST', credentials: 'include' })
if (resp.status === 401) {
// send user through normal sign-in (incl. MFA), then retry linking
} Prevention
- Require sign-in (and MFA verification) before rendering the 'Link account' UI
- Always send credentials: 'include' on same-site session-authenticated calls
- Handle session expiry by re-authenticating, then resuming the link flow
- Keep users on the same site origin so the session cookie is attached
When it happens
Trigger: POST /api/auth/oidc/{pid}?link=true without a session cookie; with an expired/invalidated session cookie; or with a session that is not verified (e.g. created but not yet passed verification/MFA step).
Common situations: User's session expired before clicking 'Link account'; session cookie blocked or stripped (same-site/cross-origin fetch without credentials); MFA not completed so the session is unverified; user attempting to link without ever signing in.
Related errors
- Unauthorized
- Account linking must be initiated with a POST request.
- OIDC sign-in failed: state cookie is missing. Try signing…
- OIDC sign-in failed: invalid state. Try signing in again.
- OIDC sign-in failed: state mismatch. Try signing in again.
AI-assisted analysis of semaphoreui/semaphore@1774ccb71a (2026-09-07).
Data as JSON: /api/errors/73ade7c6bb38abdd.
Report an issue: GitHub.
Appendix: source
Thrown at api/login.go:586
log.Error(fmt.Errorf("no such provider: %s", pid))
http.Error(w, "Unknown OIDC provider.", http.StatusNotFound)
return
}
linkMode := r.URL.Query().Get("link") != ""
if linkMode {
// POST-only: SameSite=Lax attaches the session cookie to top-level
// cross-site GET navigations, so a GET here would let an attacker
// initiate linking (CSRF) and attach their IdP identity to the
// victim's account. Lax never sends the cookie on cross-site POST.
if r.Method != http.MethodPost {
http.Error(w, "Account linking must be initiated with a POST request.", http.StatusMethodNotAllowed)
return
}
session, ok := getSession(r)
if !ok || !session.IsVerified() {
http.Error(w, "You must be signed in to link an external account.", http.StatusUnauthorized)
return
}
}
returnValue := r.URL.Query().Get("return")
if returnValue != "" {
if config.ReturnViaState {
returnPath = returnValue
} else {
redirectPath = returnValue
}
}
_, oauth, err := getOidcProvider(pid, ctx, redirectPath)
if err != nil {
log.Error(err.Error())
http.Error(w, "Failed to initialize OIDC provider. Contact your administrator.", http.StatusInternalServerError)
returnView on GitHub (pinned to 1774ccb71a)