go / workos

WorkOS provides SSO and AuthKit for OAuth flows.

I use a small client over the four WorkOS endpoints I need. The bulk of the auth security work happens in my own handler code.

Client

workos.Client is built from a Config so tests can swap the base URL and HTTP client:

const defaultAPIBase = "https://api.workos.com"

type Config struct {
	APIKey   string
	BaseURL  string
	ClientID string
	Timeout  time.Duration
}

type Client struct {
	apiKey     string
	baseURL    string
	clientID   string
	httpClient *http.Client
}

func NewClient(cfg Config) *Client {
	timeout := cfg.Timeout
	if timeout <= 0 {
		timeout = 5 * time.Second
	}
	baseURL := strings.TrimRight(cfg.BaseURL, "/")
	if baseURL == "" {
		baseURL = defaultAPIBase
	}
	return &Client{
		apiKey:     cfg.APIKey,
		baseURL:    baseURL,
		clientID:   cfg.ClientID,
		httpClient: &http.Client{Timeout: timeout},
	}
}

AuthorizationURL builds a string. No HTTP request:

func (c *Client) AuthorizationURL(organization, redirectURI, state string) string {
	q := url.Values{
		"client_id":     []string{c.clientID},
		"organization":  []string{organization},
		"redirect_uri":  []string{redirectURI},
		"response_type": []string{"code"},
		"state":         []string{state},
	}
	return c.baseURL + "/sso/authorize?" + q.Encode()
}

ProfileAndToken exchanges the callback code for a profile:

func (c *Client) ProfileAndToken(ctx context.Context, code string) (*ProfileAndTokenResponse, error) {
	form := url.Values{
		"client_id":     []string{c.clientID},
		"client_secret": []string{c.apiKey},
		"code":          []string{code},
		"grant_type":    []string{"authorization_code"},
	}
	req, err := http.NewRequestWithContext(ctx, "POST",
		c.baseURL+"/sso/token", bytes.NewBufferString(form.Encode()))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		return nil, fmt.Errorf("workos profile_and_token status=%d body=%s",
			resp.StatusCode, readBodyForError(resp.Body))
	}

	raw := map[string]any{}
	if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
		return nil, err
	}
	out := &ProfileAndTokenResponse{Raw: map[string]any{}}
	if v, ok := raw["access_token"].(string); ok {
		out.AccessToken = v
	}
	if v, ok := raw["profile"].(map[string]any); ok {
		out.Profile = v
	} else {
		out.Profile = map[string]any{}
	}
	maps.Copy(out.Raw, raw)
	return out, nil
}

There are no retry loops. All calls happen inside the user's login request cycle. Auth codes are single-use, so retrying ProfileAndToken after a partial failure would compound the problem rather than recover. Audit logging is best-effort.

Login

The login handler builds an authorization URL and stashes a CSRF state token in the session:

func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
	state, err := webutil.RandomHex(16)
	if err != nil {
		h.WriteError(w, 500, "oauth state generation failed")
		return
	}
	h.SessionSet(r, "oauth_state", state)

	workosAuthURL := h.WorkOS().AuthorizationURL(
		h.WorkOSOrganization,
		h.AbsoluteURL(r, "/sso"),
		state,
	)
	html, err := h.RenderPage(r, "sso/login", map[string]any{
		"workos_auth_url": workosAuthURL,
	})
	if err != nil {
		h.WriteError(w, 500, "render login failed")
		return
	}
	h.WriteHTML(w, 200, html)
}

The handler takes WorkOS as an interface, so tests inject a fake instead of hitting the network:

type WorkOSClient interface {
	AuthorizationURL(organization, redirectURI, state string) string
	ProfileAndToken(ctx context.Context, code string) (*workos.ProfileAndTokenResponse, error)
	CompleteAuthkitFlow(ctx context.Context, externalAuthID string, user map[string]any) (string, error)
	AuditEvent(ctx context.Context, organizationID string, event workos.AuditEvent) error
}

Callback

The callback verifies state, exchanges the code for a profile, and signs the user in:

func (h *Handler) SSOCallback(w http.ResponseWriter, r *http.Request) {
	expectedState := h.SessionGet(r, "oauth_state")
	h.SessionDelete(r, "oauth_state")
	if expectedState == "" || r.URL.Query().Get("state") != expectedState {
		h.FlashError(r, "Forbidden")
		h.Redirect(w, r, "/login")
		return
	}
	code := r.URL.Query().Get("code")
	if code == "" {
		h.FlashError(r, "Forbidden")
		h.Redirect(w, r, "/login")
		return
	}

	resp, err := h.WorkOS().ProfileAndToken(r.Context(), code)
	if err != nil {
		h.FlashError(r, "SSO error. Please try again.")
		h.Redirect(w, r, "/login")
		return
	}
	email, _ := resp.Profile["email"].(string)

	user, err := h.lookupActiveLoginUserByEmail(r.Context(), email)
	if err != nil || user == nil {
		h.FlashError(r, "Forbidden")
		h.Redirect(w, r, "/login")
		return
	}

	h.SetRememberCookie(w, user.RememberToken, h.CookieSecret, h.AppEnv)
	h.Redirect(w, r, h.PopReturnTo(r))
}

lookupActiveLoginUserByEmail queries WHERE active = true, so deactivated users can't log in.

Audit logs

After a successful login, write a WorkOS audit event. The Idempotency-Key header is required by the audit logs endpoint, so retries (here or upstream) are safe:

func (c *Client) AuditEvent(ctx context.Context, organizationID string, event AuditEvent) error {
	payload, err := json.Marshal(map[string]any{
		"organization_id": organizationID,
		"event":           event,
	})
	if err != nil {
		return err
	}
	req, err := http.NewRequestWithContext(ctx, "POST",
		c.baseURL+"/audit_logs/events", bytes.NewReader(payload))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	idempotency, err := idempotencyKey()
	if err != nil {
		return err
	}
	req.Header.Set("Idempotency-Key", idempotency)

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode/100 != 2 {
		return fmt.Errorf("workos audit_event status=%d body=%s",
			resp.StatusCode, readBodyForError(resp.Body))
	}
	return nil
}

The callback logs the returned error to Sentry but does not fail the request. A missing audit log shouldn't block a login.

AuthKit bridge

The same login and callback handlers also serve an MCP AuthKit flow. When a client starts OAuth with AuthKit, AuthKit redirects to GET /login?external_auth_id=xxx.

If the user is signed in, stash the value in the session and render a consent page:

var externalAuthIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,255}$`)

func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
	u, err := h.CurrentUser(r)
	if err != nil {
		h.WriteError(w, 500, "auth lookup failed")
		return
	}
	externalAuthID := strings.TrimSpace(r.URL.Query().Get("external_auth_id"))
	validExternalAuthID := externalAuthIDPattern.MatchString(externalAuthID)

	if u != nil && u.Active && validExternalAuthID {
		h.SessionSet(r, "pending_external_auth_id", externalAuthID)
		html, _ := h.RenderPage(r, "sso/authkit_confirm", map[string]any{
			"form_authenticity_token": h.SessionGet(r, "csrf_token"),
		})
		h.WriteHTML(w, 200, html)
		return
	}
	// ... otherwise, normal SSO flow
}

A CSRF-protected POST from the consent page completes the bind:

func (h *Handler) SSOConfirm(w http.ResponseWriter, r *http.Request) {
	u := h.RequestUser(r)
	if u == nil || !u.Active {
		h.Redirect(w, r, "/login")
		return
	}
	externalAuthID := strings.TrimSpace(h.SessionGet(r, "pending_external_auth_id"))
	h.SessionDelete(r, "pending_external_auth_id")
	if !externalAuthIDPattern.MatchString(externalAuthID) {
		h.FlashError(r, "MCP authentication session expired. Please try again.")
		h.Redirect(w, r, "/login")
		return
	}

	redirectURI, err := h.WorkOS().CompleteAuthkitFlow(r.Context(), externalAuthID, map[string]any{
		"id":    strconv.FormatInt(u.ID, 10),
		"email": u.Email,
	})
	if err != nil || !AuthkitAllowedRedirectURI(redirectURI) {
		h.FlashError(r, "MCP authentication error. Please try again.")
		h.Redirect(w, r, "/login")
		return
	}
	w.Header().Set("Location", redirectURI)
	w.WriteHeader(303)
}

Without the consent step, an attacker could email a logged-in victim a link with ?external_auth_id=ATTACKER_ID and silently bind their AuthKit identity to the victim's account on click. A CSRF-protected POST blocks that.

For guests, the callback path stashes external_auth_id in the session and completes AuthKit there instead of ending the request with a logged-in session. State already protects that path.

The completion call returns an AuthKit-issued redirect URL. Check the host before redirecting:

func AuthkitAllowedRedirectURI(raw string) bool {
	uri, err := url.Parse(raw)
	if err != nil {
		return false
	}
	if uri.Scheme != "https" {
		return false
	}
	host := strings.ToLower(uri.Hostname())
	return strings.HasSuffix(host, ".workos.com") || strings.HasSuffix(host, ".authkit.app")
}

The host check is defense in depth against open redirects.

Tests

Stub the HTTP boundary with httptest.NewServer and point the client's base URL at it:

func TestProfileAndToken(t *testing.T) {
	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		tu.OK(r.URL.Path == "/sso/token")
		tu.OK(r.Header.Get("Content-Type") == "application/x-www-form-urlencoded")
		w.Write([]byte(`{"access_token":"tok_123","profile":{"email":"[email protected]"}}`))
	}))
	defer s.Close()

	c := NewClient(Config{APIKey: "key", ClientID: "cid", BaseURL: s.URL})
	c.httpClient = s.Client()

	out, err := c.ProfileAndToken(context.Background(), "code-123")
	tu.OK(err == nil)
	tu.OK(out.AccessToken == "tok_123")
	tu.OK(out.Profile["email"] == "[email protected]")
}

The handler tests inject a fake WorkOSClient, so the login, callback, and consent paths run without a network or a real WorkOS account.

← All articles