skills/implementing-scalekit-go-auth/SKILL.md
Guides Go developers implementing Scalekit authentication in Gin-based web apps using scalekit-sdk-go. Use when the developer mentions Scalekit, enterprise SSO, OIDC login, OAuth2 callback, access token validation, token refresh, session cookies, logout, IDP-initiated login, or xoid/xuid JWT claims in a Go project.
npx skillsauth add scalekit-inc/skills implementing-scalekit-go-authInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Scalekit is an OIDC/OAuth2 provider. Unlike frameworks that auto-wire OAuth2, Go requires you to
manually implement four handlers: authorize → callback → session → logout. Use scalekit-sdk-go/v2.
go get github.com/scalekit-inc/scalekit-sdk-go/v2
go get github.com/gin-gonic/gin
go get github.com/gin-contrib/cors
go get github.com/golang-jwt/jwt/v5
SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.dev
SCALEKIT_CLIENT_ID=your_client_id
SCALEKIT_CLIENT_SECRET=your_client_secret
PORT=8080
Never commit secrets. Load with godotenv or equivalent.
Use sync.Once so the client is created exactly once across all requests:
var (
globalClient scalekit.Scalekit
clientOnce sync.Once
clientErr error
)
func GetScaleKitClient() (scalekit.Scalekit, error) {
clientOnce.Do(func() {
envURL := os.Getenv("SCALEKIT_ENVIRONMENT_URL")
id := os.Getenv("SCALEKIT_CLIENT_ID")
secret := os.Getenv("SCALEKIT_CLIENT_SECRET")
globalClient = scalekit.NewScalekitClient(envURL, id, secret)
})
return globalClient, clientErr
}
Call GetScaleKitClient() once at startup to fail fast on bad credentials.
GET /api/authorize
→ GetAuthorizationUrl() → 302 to Scalekit
GET /api/scalekit/callback?code=...
→ AuthenticateWithCode() → redirect to /dashboard or /onboarding
GET /api/session (every page load)
→ ValidateAccessToken() → refresh if expired → return user JSON
GET /api/logout
→ GetLogoutUrl() → clear cookies → 302 to Scalekit end-session
Builds the authorization URL and redirects the browser:
func AuthorizeHandler(c *gin.Context) {
sc, _ := GetScaleKitClient()
stateBytes, _ := json.Marshal(map[string]any{
"next": c.Query("next"),
"csrf": randomString(12),
})
state := base64.StdEncoding.EncodeToString(stateBytes)
opts := scalekit.AuthorizationUrlOptions{
State: state,
Scopes: []string{"openid", "profile", "email", "offline_access"},
}
// Scope to a specific org, connection, or hint when provided
if v := c.Query("organization_id"); v != "" { opts.OrganizationId = v }
if v := c.Query("connection_id"); v != "" { opts.ConnectionId = v }
if v := c.Query("login_hint"); v != "" { opts.LoginHint = v }
authURL, err := sc.GetAuthorizationUrl(callbackURL(c), opts)
if err != nil {
c.JSON(500, gin.H{"error": "Failed to build authorization URL"})
return
}
c.Redirect(http.StatusFound, authURL.String())
}
func callbackURL(c *gin.Context) string {
proto := "https"
if strings.Contains(c.Request.Host, "localhost") { proto = "http" }
return proto + "://" + c.Request.Host + "/api/scalekit/callback"
}
Exchange the authorization code for tokens; set httpOnly cookies:
func CallbackHandler(c *gin.Context) {
if e := c.Query("error"); e != "" {
c.JSON(400, gin.H{"error": c.Query("error_description")})
return
}
sc, _ := GetScaleKitClient()
resp, err := sc.AuthenticateWithCode(
c.Request.Context(),
c.Query("code"),
callbackURL(c),
scalekit.AuthenticationOptions{},
)
if err != nil {
c.JSON(500, gin.H{"error": "Token exchange failed"})
return
}
c.SetCookie("auth_access_token", resp.AccessToken, 86400, "/", "", false, true)
c.SetCookie("auth_refresh_token", resp.RefreshToken, 2592000, "/", "", false, true)
c.SetCookie("id_token", resp.IdToken, 86400, "/", "", false, false)
// Route: no org in token → new user needs onboarding
claims, _ := decodeJWTPayload(resp.AccessToken)
redirect := "/onboarding"
if _, hasOrg := claims["xoid"]; hasOrg {
redirect = "/dashboard"
}
c.Redirect(http.StatusFound, getUIBaseURL(c)+redirect)
}
resp fields: AccessToken, RefreshToken, IdToken, User (email, name, etc.).
Validate on every authenticated page load; silently refresh expired tokens:
func SessionHandler(c *gin.Context) {
accessToken, _ := c.Cookie("auth_access_token")
refreshToken, _ := c.Cookie("auth_refresh_token")
sc, _ := GetScaleKitClient()
valid, err := sc.ValidateAccessToken(c.Request.Context(), accessToken)
if err != nil || !valid {
refreshed, err := sc.RefreshAccessToken(c.Request.Context(), refreshToken)
if err != nil {
LogoutHandler(c) // force re-login
return
}
c.SetCookie("auth_access_token", refreshed.AccessToken, 86400, "/", "", false, true)
c.SetCookie("auth_refresh_token", refreshed.RefreshToken, 2592000, "/", "", false, true)
accessToken = refreshed.AccessToken
}
claims, _ := decodeJWTPayload(accessToken)
userID, _ := getStringClaim(claims, "sub")
userResp, _ := sc.User().GetUser(context.Background(), userID)
c.JSON(200, gin.H{
"authenticated": true,
"user": gin.H{
"id": userResp.User.Id,
"email": userResp.User.Email,
"first_name": userResp.User.UserProfile.FirstName,
"last_name": userResp.User.UserProfile.LastName,
},
})
}
Clear all cookies and redirect to Scalekit's end-session endpoint:
func LogoutHandler(c *gin.Context) {
idToken, _ := c.Cookie("id_token")
sc, _ := GetScaleKitClient()
logoutURL, _ := sc.GetLogoutUrl(scalekit.LogoutUrlOptions{
IdTokenHint: idToken,
PostLogoutRedirectUri: getUIBaseURL(c),
})
c.SetCookie("auth_access_token", "", -1, "/", "", false, true)
c.SetCookie("auth_refresh_token", "", -1, "/", "", false, true)
c.SetCookie("id_token", "", -1, "/", "", false, false)
c.Redirect(http.StatusFound, logoutURL.String())
}
When an IdP starts the login (e.g. Okta tile click), Scalekit sends a signed JWT:
func IdpInitiatedLoginHandler(c *gin.Context) {
sc, _ := GetScaleKitClient()
claims, err := sc.GetIdpInitiatedLoginClaims(
c.Request.Context(),
c.Query("idp_initiated_login"),
)
if err != nil {
c.JSON(400, gin.H{"error": "invalid idp_initiated_login token"})
return
}
opts := scalekit.AuthorizationUrlOptions{
Scopes: []string{"openid", "profile", "email", "offline_access"},
}
if claims.OrganizationID != "" { opts.OrganizationId = claims.OrganizationID }
if claims.ConnectionID != "" { opts.ConnectionId = claims.ConnectionID }
if claims.LoginHint != "" { opts.LoginHint = claims.LoginHint }
authURL, _ := sc.GetAuthorizationUrl(callbackURL(c), opts)
c.Redirect(http.StatusFound, authURL.String())
}
// decodeJWTPayload decodes the payload of a JWT without verifying the signature.
// Always use ValidateAccessToken() for security — this is only for claim extraction after validation.
func decodeJWTPayload(token string) (map[string]interface{}, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, err
}
var claims map[string]interface{}
return claims, json.Unmarshal(payload, &claims)
}
func getStringClaim(claims map[string]interface{}, key string) (string, error) {
v, ok := claims[key].(string)
if !ok || v == "" {
return "", fmt.Errorf("claim %q missing or empty", key)
}
return v, nil
}
| Claim | Meaning | Notes |
|---|---|---|
| sub | Scalekit user ID | Always present |
| xoid | External org ID (e.g. wspace_abc) | Absent → user has no org yet → send to /onboarding |
| xuid | Your app's user DB ID | Absent → create user locally, then call UpdateUser to write it back |
| permissions | User permissions in org | Check before authorizing sensitive actions |
| roles | User roles in org | Derive is_admin from role names |
api := r.Group("/api")
api.GET("/authorize", AuthorizeHandler)
api.GET("/login/initiate", IdpInitiatedLoginHandler)
api.GET("/scalekit/callback", CallbackHandler)
api.GET("/session", SessionHandler)
api.GET("/logout", LogoutHandler)
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"https://yourdomain.com"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true, // MUST be true when cookies carry tokens
MaxAge: 12 * time.Hour,
}))
- [ ] Step 1: go get scalekit-sdk-go/v2, gin, cors, jwt/v5
- [ ] Step 2: Set SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, SCALEKIT_CLIENT_SECRET in .env
- [ ] Step 3: Create handlers/client.go — sync.Once singleton
- [ ] Step 4: Create handlers/utils.go — decodeJWTPayload, getStringClaim, callbackURL, getUIBaseURL
- [ ] Step 5: Implement AuthorizeHandler → GetAuthorizationUrl → redirect
- [ ] Step 6: Implement CallbackHandler → AuthenticateWithCode → set cookies → redirect
- [ ] Step 7: Implement SessionHandler → ValidateAccessToken → RefreshAccessToken if expired
- [ ] Step 8: Implement LogoutHandler → GetLogoutUrl → clear cookies → redirect
- [ ] Step 9: Register all four routes under /api
- [ ] Step 10: Configure CORS with AllowCredentials: true
- [ ] Step 11: Register callback URI in Scalekit dashboard
- [ ] Step 12: Test: login → /dashboard → GET /api/session → logout
invalid_grant on callback: The redirectURL in AuthenticateWithCode must exactly match the URI registered in the Scalekit dashboard — including scheme and path. One mismatch silently breaks the exchange.
Session handler stuck in logout loop: ValidateAccessToken returns false on both expiry and network errors. Log err before deciding to refresh vs. logout so you can distinguish the two.
xoid missing: The user has no organization. This is expected for new signups — route to /onboarding to create or join a workspace.
CORS / cookie not sent: Ensure AllowCredentials: true is set in CORS config. Without it, the browser strips cookies from cross-origin requests.
toExternalWorkspaceID format: Internal org IDs are org_<id>. Strip the prefix and prepend wspace_ to get the external workspace ID used in the access token's xoid claim.
Gin's c.SetCookie does not expose a SameSite parameter. Use http.SetCookie directly for full control:
http.SetCookie(c.Writer, &http.Cookie{
Name: "auth_access_token",
Value: resp.AccessToken,
Path: "/",
MaxAge: 86400,
HttpOnly: true,
SameSite: http.SameSiteLaxMode, // Required — Strict drops cookie on OAuth redirect back
Secure: !strings.Contains(c.Request.Host, "localhost"),
})
SameSite: Strict drops the session cookie on the cross-origin redirect from Scalekit back to /api/scalekit/callback — the callback receives no cookies and the auth flow breaks silently.
Never hardcode secure: false. Detect localhost at runtime:
func isSecure(c *gin.Context) bool {
return !strings.Contains(c.Request.Host, "localhost")
}
Pass Secure: isSecure(c) when setting every cookie. This ensures Secure is always set in production (HTTPS) without breaking local development.
The base64-encoded state in AuthorizeHandler already carries a CSRF token ("csrf": randomString(12)). Validate it in CallbackHandler before exchanging the code:
stateRaw, err := base64.StdEncoding.DecodeString(c.Query("state"))
if err != nil {
c.JSON(400, gin.H{"error": "invalid state"})
return
}
var stateData map[string]string
json.Unmarshal(stateRaw, &stateData)
// Optionally compare stateData["csrf"] against a cookie set in AuthorizeHandler
For stronger CSRF protection, store the csrf value in a short-lived cookie in AuthorizeHandler and verify it matches in CallbackHandler.
The state JSON already includes "next". After a successful callback, extract it and redirect:
next := stateData["next"]
if next == "" || !strings.HasPrefix(next, "/") {
next = "/dashboard" // prevent open redirect
}
c.Redirect(http.StatusFound, getUIBaseURL(c)+next)
Never redirect to an absolute URL from state — only relative paths starting with /.
After logout, the browser back button can serve a cached /api/session response showing the user as authenticated. Add the header on every session/protected response:
func SessionHandler(c *gin.Context) {
c.Header("Cache-Control", "no-store")
// ...
}
Multiple browser tabs hitting /api/session simultaneously can each attempt a refresh with the same refresh token — the second call will receive invalid_grant. Use a per-user mutex or a distributed lock:
var refreshMu sync.Map // keyed by refresh token hash
func SessionHandler(c *gin.Context) {
refreshToken, _ := c.Cookie("auth_refresh_token")
key := fmt.Sprintf("%x", sha256.Sum256([]byte(refreshToken)))
mu, _ := refreshMu.LoadOrStore(key, &sync.Mutex{})
mu.(*sync.Mutex).Lock()
defer mu.(*sync.Mutex).Unlock()
// ...refresh logic...
}
For stateless deployments, treat invalid_grant on refresh as a session expiry and redirect to login rather than erroring.
If a JavaScript frontend calls /api/session and gets a 302 redirect, the browser follows it silently and the client receives HTML instead of JSON. Return 401 for Accept: application/json requests:
func SessionHandler(c *gin.Context) {
accessToken, err := c.Cookie("auth_access_token")
if err != nil || accessToken == "" {
if strings.Contains(c.GetHeader("Accept"), "application/json") {
c.JSON(401, gin.H{"error": "unauthenticated"})
} else {
c.Redirect(http.StatusFound, "/login")
}
return
}
// ...
}
tools
Create or review Scalekit custom providers/connectors for proxy-only usage, including MCP providers. Use this skill when the task is to gather API docs, infer whether a connector is OAuth, Basic, Bearer, or API Key, determine if it is an MCP provider, determine required tracked fields like domain or version, generate provider JSON, check for existing custom providers, show update diffs, run approved create or update curls, and print resolved delete curls.
tools
Use when a developer is new to Scalekit and needs guidance on where to start, doesn't know which auth plugin or skill to choose, wants to connect an AI agent or agentic workflow to third-party services (Gmail, Slack, Notion, Google Calendar), needs OAuth or tool-calling auth for agents, wants to add authentication to a project but hasn't chosen an approach yet, or needs to install the Scalekit plugin for their AI coding tool (Claude Code, Codex, Copilot CLI, Cursor, or other agents).
tools
Use when a user asks to generate, review, validate, or fix any code snippet that uses Scalekit APIs or SDKs. This skill is the single source of truth for Scalekit code correctness — it can generate illustration-quality snippets from scratch (for docs, websites, or integration guides) and review existing code to catch wrong method names, missing parameters, security anti-patterns, and broken auth flows. Covers all four SDKs (Node, Python, Go, Java), raw REST API calls, and both Scalekit product suites — SaaSKit (SSO, login, sessions, RBAC, SCIM) and AgentKit (connections, tool calling, MCP auth). Use when the user says review my Scalekit code, generate a Scalekit example, validate this auth flow, check my SDK usage, fix my Scalekit integration, write a code sample for docs, or anything involving Scalekit code quality.
development
Walks through a structured production readiness checklist for Scalekit SSO implementations. Use when the user says they are going live, launching to production, doing a pre-launch review, hardening their SSO setup, or wants to verify their Scalekit implementation is production-ready.