sstart supports Single Sign-On (SSO) authentication via OIDC (OpenID Connect). When SSO is configured, sstart will automatically authenticate users before fetching secrets from providers. The obtained tokens can then be used by providers that require OIDC-based authentication (e.g., Vault/OpenBao with JWT auth).
sstart supports two authentication flows:
| Flow | When Used | Requirements | Use Case |
|---|---|---|---|
| Interactive (Browser) | SSTART_SSO_SECRET not set |
Client ID only | Local development, user authentication |
| Client Credentials | SSTART_SSO_SECRET is set |
Client ID + Client Secret | CI/CD, automated pipelines, service accounts |
When no client secret is configured, sstart uses the PKCE (Proof Key for Code Exchange) flow:
- A local HTTP server starts on port 5747
- Your default browser opens to the OIDC provider's login page
- After successful authentication, tokens are cached locally
- The tokens are used for provider authentication
When SSTART_SSO_SECRET is set, sstart uses the OAuth2 client credentials flow:
- sstart calls the OIDC provider's token endpoint directly
- No browser is opened
- Tokens are obtained automatically
- Perfect for CI/CD environments
Important: If client credentials are configured but authentication fails, sstart will return an errorβit will NOT fall back to browser-based authentication.
Add the sso section to your .sstart.yml:
sso:
oidc:
clientId: your-client-id # Required: OIDC client ID
issuer: https://auth.example.com # Required: OIDC issuer URL
scopes: # Required: OIDC scopes
- openid
- profile
- email
providers:
- kind: vault
path: secret/myapp
auth:
method: jwt
role: your-role| Field | Required | Description |
|---|---|---|
clientId |
Yes | The OIDC client ID registered with your identity provider |
issuer |
Yes | The OIDC issuer URL (e.g., https://auth.example.com) |
scopes |
Yes | List of OIDC scopes to request. Must include at least one scope. Common scopes: openid, profile, email |
pkce |
No | Explicitly enable PKCE flow (true/false). Defaults to true when client secret is not set |
redirectUri |
No | Custom redirect URI. Defaults to http://localhost:5747/auth/sstart |
responseMode |
No | OIDC response mode (e.g., query, fragment) |
| Variable | Description |
|---|---|
SSTART_SSO_SECRET |
The OIDC client secret. When set, enables client credentials flow (non-interactive). When not set, uses browser-based PKCE flow. |
Note: The client secret can ONLY be provided via the SSTART_SSO_SECRET environment variable. It is intentionally NOT supported in the YAML config file to prevent accidentally committing secrets to version control.
Scopes can be specified as either an array or a space-separated string:
# Array format
scopes:
- openid
- profile
- email
# Space-separated string format
scopes: "openid profile email"# .sstart.yml
sso:
oidc:
clientId: my-public-client
issuer: https://auth.example.com
scopes:
- openid
- profile# Just run sstart - browser will open for login
sstart run -- ./my-app# .sstart.yml (same config - no secret in file!)
sso:
oidc:
clientId: my-service-account
issuer: https://auth.example.com
scopes:
- openid
- profile# Set the secret via environment variable
export SSTART_SSO_SECRET="your-client-secret"
# sstart will use client credentials flow - no browser
sstart run -- ./my-appjobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run with secrets
env:
SSTART_SSO_SECRET: ${{ secrets.OIDC_CLIENT_SECRET }}
run: |
sstart run -- ./deploy.shsstart stores SSO tokens securely using the system keyring when available, with automatic fallback to file storage.
| Backend | Platform | Description |
|---|---|---|
| Keyring (default) | macOS, Windows, Linux | Uses the OS-native secure credential storage |
| File (fallback) | All platforms | Falls back to ~/.config/sstart/tokens.json with 0600 permissions |
- macOS: Keychain
- Windows: Windows Credential Manager
- Linux: Secret Service (GNOME Keyring, KWallet, etc.)
sstart automatically detects if keyring is available. If not (e.g., in CI/CD environments, headless servers, or containers), it falls back to file-based storage.
The following tokens are stored:
- Access Token: Used for authenticating with providers
- Refresh Token: Used to obtain new access tokens when expired
- ID Token: Contains user identity claims
- Expiry: Token expiration timestamp
When tokens expire, sstart automatically attempts to refresh them using the refresh token. If refresh fails (e.g., refresh token expired), a new authentication flow is initiated.
Providers can access SSO tokens via their configuration to authenticate API requests. The tokens are injected into the provider config with special keys:
| Config Key | Description |
|---|---|
_sso_access_token |
The OIDC access token |
_sso_id_token |
The OIDC ID token |
Providers that support OIDC authentication can use these tokens to authenticate their API calls. For example, a provider could use the access token as a Bearer token:
// Inside a provider's Fetch implementation
if accessToken, ok := config["_sso_access_token"].(string); ok {
req.Header.Set("Authorization", "Bearer "+accessToken)
}Note: SSO tokens are only used for provider authentication. They are NOT injected as environment variables into the subprocess.
sso:
oidc:
clientId: 351633448147908967
issuer: https://your-instance.zitadel.cloud
scopes:
- openid
- profile
- emailsso:
oidc:
clientId: sstart-cli
issuer: https://keycloak.example.com/realms/myrealm
scopes:
- openid
- profile
- emailsso:
oidc:
clientId: abc123xyz
issuer: https://your-tenant.auth0.com
scopes:
- openid
- profile
- emailsso:
oidc:
clientId: 0oaxxxxxxxx
issuer: https://your-org.okta.com
scopes:
- openid
- profile
- emailsso:
oidc:
clientId: your-client-id.apps.googleusercontent.com
issuer: https://accounts.google.com
scopes:
- openid
- profile
- emailsso:
oidc:
clientId: your-application-id
issuer: https://login.microsoftonline.com/your-tenant-id/v2.0
scopes:
- openid
- profile
- emailWhen using SSO with HashiCorp Vault or OpenBao, sstart can use the OIDC tokens to authenticate with Vault's JWT auth backend. This allows users to access secrets without managing static Vault tokens.
- User authenticates via OIDC (interactively or via client credentials)
- sstart obtains an ID token from the OIDC provider
- The ID token is sent to Vault/OpenBao's JWT auth backend
- Vault validates the token and returns a Vault token
- sstart uses the Vault token to fetch secrets
Configure the Vault provider with the auth block:
sso:
oidc:
clientId: your-client-id
issuer: https://auth.example.com
scopes:
- openid
- profile
- email
providers:
- kind: vault
address: https://vault.example.com
path: secret/myapp
auth:
method: jwt # or "oidc" - both work the same way
role: your-vault-role # Required: the JWT auth role in Vault
mount: jwt # Optional: auth backend mount path (default: "jwt")| Field | Required | Description |
|---|---|---|
auth.method |
Yes | Set to oidc or jwt to use SSO tokens for authentication |
auth.role |
Yes | The Vault JWT auth role name to authenticate as |
auth.mount |
No | The mount path of the JWT auth backend (default: jwt) |
You need to configure Vault/OpenBao to accept JWT tokens from your OIDC provider.
# For Vault
vault auth enable jwt
# For OpenBao
bao auth enable jwtConfigure the backend to trust your OIDC provider:
# For Vault
vault write auth/jwt/config \
oidc_discovery_url="https://auth.example.com" \
default_role="sstart"
# For OpenBao
bao write auth/jwt/config \
oidc_discovery_url="https://auth.example.com" \
default_role="sstart"Replace https://auth.example.com with your OIDC issuer URL.
Create a role that maps OIDC users to Vault policies:
# For Vault
vault write auth/jwt/role/sstart \
role_type="jwt" \
bound_audiences="your-client-id" \
user_claim="sub" \
policies="your-policy" \
ttl="1h"
# For OpenBao
bao write auth/jwt/role/sstart \
role_type="jwt" \
bound_audiences="your-client-id" \
user_claim="sub" \
policies="your-policy" \
ttl="1h"Important:
role_typemust bejwt(notoidc) because sstart passes the token directlybound_audiencesmust match your OIDC client ID exactly
Create a policy that grants access to your secrets:
# Create policy file
cat > sstart-policy.hcl << EOF
path "secret/data/myapp/*" {
capabilities = ["read", "list"]
}
EOF
# For Vault
vault policy write sstart-policy sstart-policy.hcl
# For OpenBao
bao policy write sstart-policy sstart-policy.hclImportant: Zitadel has different application types with different capabilities:
| Application Type | Client Credentials Flow | Interactive Flow | Use Case |
|---|---|---|---|
| Web App | β Not supported | β Yes | Browser-based applications |
| Native App | β Not supported | β Yes (PKCE) | CLI tools, mobile apps |
| Service User | β Yes | β No | CI/CD, automated pipelines |
For interactive use (local development):
- Create a Native App in Zitadel
- Enable PKCE (usually default)
- Set Redirect URI:
http://localhost:5747/auth/sstart
For CI/CD (client credentials flow):
- Go to Users β Service Users β + New
- Create a service user with:
- User Name:
sstart-ci(or any name) - Access Token Type: JWT
- User Name:
- After creation, go to the service user β Actions β Generate Client Secret
- Copy the User ID (this is your Client ID) and Client Secret
β οΈ Common Mistake: Web Apps in Zitadel do NOT supportclient_credentialsgrant type. The grant type options (Implicit, Device Code, Refresh Token, Token Exchange) do not include Client Credentials. You MUST use a Service User for CI/CD.
-
Note your Client ID:
- For Web/Native Apps: The Application's Client ID
- For Service Users: The User ID (found in user details)
-
For CI/CD: The client secret from the Service User
# Enable JWT auth
bao auth enable jwt
# Configure to trust Zitadel
bao write auth/jwt/config \
oidc_discovery_url="https://your-instance.zitadel.cloud"
# Create role
bao write auth/jwt/role/sstart \
role_type="jwt" \
bound_audiences="351633448147908967" \
user_claim="sub" \
policies="sstart-policy" \
ttl="1h"
# Create policy for reading secrets
bao policy write sstart-policy - << EOF
path "secret/data/*" {
capabilities = ["read", "list"]
}
EOFsso:
oidc:
clientId: 351633448147908967
issuer: https://your-instance.zitadel.cloud
scopes:
- openid
- profile
- email
providers:
- kind: vault
address: https://vault.example.com
path: secret/myapp
auth:
method: jwt
role: sstart# Interactive (browser login)
sstart show
# Non-interactive (CI/CD)
export SSTART_SSO_SECRET="your-client-secret"
sstart showThe SSO end-to-end tests require a real OIDC provider. We use Zitadel for testing.
| Variable | Description |
|---|---|
SSTART_E2E_SSO_ISSUER |
OIDC issuer URL (e.g., https://your-instance.zitadel.cloud) |
SSTART_E2E_SSO_CLIENT_ID |
Zitadel Service User ID |
SSTART_E2E_SSO_CLIENT_SECRET |
Zitadel Service User client secret |
SSTART_E2E_SSO_AUDIENCE |
(Optional) Expected audience, defaults to client ID |
-
Create a Zitadel instance at zitadel.cloud (free tier available)
-
Create a Service User (NOT a Web App!):
Users β Service Users β + New - User Name: sstart-e2e-test - Access Token Type: JWT -
Generate a Client Secret:
Click on the service user β Actions β Generate Client Secret -
Set environment variables:
export SSTART_E2E_SSO_ISSUER="https://your-instance.zitadel.cloud" export SSTART_E2E_SSO_CLIENT_ID="<service user id>" export SSTART_E2E_SSO_CLIENT_SECRET="<generated client secret>"
# Run all SSO tests
go test -v ./tests/end2end -run TestE2E_SSO_
# Run a specific test
go test -v ./tests/end2end -run TestE2E_SSO_ClientCredentialsFlowThis usually means you're using a Web App instead of a Service User. Web Apps in Zitadel don't support client_credentials grant type.
Solution: Create a Service User as described above.
The token storage uses a global keyring location. If tests fail intermittently, leftover tokens from previous tests might be interfering.
Solution: The tests include cleanup logic, but if issues persist:
# macOS: Clear keyring tokens
security delete-generic-password -s sstart -a sso-tokens
# Linux: Clear file-based tokens
rm ~/.config/sstart/tokens.jsonFor GitHub Actions, store the SSO credentials in your secrets manager (e.g., Infisical) and inject them during the workflow:
env:
SSTART_E2E_SSO_ISSUER: ${{ env.SSTART_E2E_SSO_ISSUER }}
SSTART_E2E_SSO_CLIENT_ID: ${{ env.SSTART_E2E_SSO_CLIENT_ID }}
SSTART_E2E_SSO_CLIENT_SECRET: ${{ env.SSTART_E2E_SSO_CLIENT_SECRET }}If the browser doesn't open automatically, the login URL will be printed to the terminal. Copy and paste it into your browser manually.
π Opening browser for authentication...
If the browser doesn't open, visit: http://localhost:5747/login
If port 5747 is already in use, the authentication will fail. Ensure no other application is using this port, or wait for the previous sstart process to complete.
If you see authentication errors, your tokens may have expired and the refresh token is no longer valid. sstart will automatically initiate a new login flow.
To force a fresh login, you can use the --force-auth flag:
sstart --force-auth showOr manually clear the stored tokens:
macOS (Keychain):
security delete-generic-password -s sstart -a sso-tokensLinux (if using file fallback):
rm ~/.config/sstart/tokens.jsonWindows (Credential Manager): Use the Windows Credential Manager UI to remove the "sstart" credential.
The authentication flow times out after 5 minutes. If you don't complete the login within this time, sstart will fail with a timeout error. Simply run the command again to restart the authentication.
If the client credentials flow fails, check:
- Client secret is correct: Verify
SSTART_SSO_SECRETis set correctly - Grant type enabled: Ensure your OIDC client has
client_credentialsgrant type enabled - Scopes allowed: Some providers require specific scopes for client credentials
This error means you're using a Web App or Native App, which don't support client credentials flow.
Solution: Use a Service User instead:
- Go to Users β Service Users β + New
- Create the service user
- Generate a Client Secret for it
- Use the User ID as your Client ID
Web Apps in Zitadel only support these grant types: Implicit, Device Code, Refresh Token, Token Exchange β NOT Client Credentials.
This usually means the JWT validation failed. Check:
-
Audience mismatch: Verify
bound_audiencesmatches your OIDC client ID exactlybao read auth/jwt/role/sstart -
Issuer not configured: Verify the OIDC discovery URL is set
bao read auth/jwt/config -
Role doesn't exist: Verify the role exists
bao list auth/jwt/role
The role is configured with role_type="oidc" but sstart requires role_type="jwt". Update the role:
bao write auth/jwt/role/sstart \
role_type="jwt" \
bound_audiences="your-client-id" \
user_claim="sub" \
policies="your-policy" \
ttl="1h"-
Client Secret via Environment Only: The client secret can ONLY be provided via
SSTART_SSO_SECRETenvironment variable, never in config files. This prevents accidentally committing secrets to version control. -
Token Storage: Tokens are stored in the system keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service) when available. This provides OS-level encryption and access control. Falls back to file storage with restrictive permissions (0600) when keyring is unavailable.
-
PKCE: When no client secret is configured, sstart uses PKCE flow for better security in interactive CLI applications.
-
No Fallback: When client credentials are configured, sstart will NOT fall back to browser-based authentication if authentication fails. This ensures predictable behavior in CI/CD.
-
Localhost Callback: The callback server only binds to
127.0.0.1, preventing external access. -
Session Cookies: Secure, HTTP-only cookies are used during the authentication flow.
-
No Token Injection: SSO tokens are NOT injected into subprocess environment variables, limiting exposure.