| name | speckle-errors-auth |
| description | Use when debugging Speckle authentication failures, token expiry issues, or permission denied errors. Prevents PAT scope mismatch, OAuth flow misconfiguration, and confusing server vs cloud authentication endpoints. Covers auth errors (token expiry, scope mismatch, OAuth flow errors), PAT vs application token confusion, server vs cloud auth differences, refresh token failures, SSO issues, and scope debugging. Keywords: speckle auth error, token expired, permission denied, scope mismatch, PAT, oauth error, 401, 403, unauthorized, forbidden, can't login, access denied, token not working.
|
| license | MIT |
| compatibility | Designed for Claude Code. Requires Speckle Server 2.x/3.x, SpecklePy or Speckle.Sdk. |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
speckle-errors-auth
Quick Reference
Authentication Error Decision Tree
Error encountered
├── HTTP 401 / GraphQL "UNAUTHENTICATED"
│ ├── No token provided → Add Authorization: Bearer <token> header
│ ├── Token malformed → Check for whitespace, newlines, or truncation in token string
│ ├── Token expired → Refresh token (OAuth) or generate new PAT
│ ├── Token revoked → Generate new token, check if admin revoked it
│ └── Wrong server URL → Verify token matches the target server instance
│
├── HTTP 403 / GraphQL "FORBIDDEN"
│ ├── Missing scope → Check required scope for the operation (see Scope Table)
│ ├── Wrong project role → User needs contributor/owner, has only viewer
│ ├── Resource-scoped token → Token is limited to specific projects/workspaces
│ └── Server role insufficient → User needs SERVER_USER or SERVER_ADMIN role
│
├── GraphQL "NOT_FOUND" (may mask auth error)
│ └── User lacks access to resource → Speckle returns NOT_FOUND instead of FORBIDDEN
│ for resources the user cannot see at all
│
├── OAuth flow failure
│ ├── Challenge mismatch → ALWAYS store and reuse the SAME challenge string
│ ├── Invalid redirect URI → URI MUST exactly match the registered redirect URI
│ ├── Expired access code → Exchange the code immediately after callback
│ └── Wrong appId/appSecret → Verify credentials match the registered application
│
└── Refresh token failure
├── Refresh token expired → User MUST re-authenticate from scratch
├── Refresh token already used → Tokens are single-use; store the NEW refresh token
└── Wrong appId/appSecret → MUST use the same app credentials as original auth
Error Code Quick Map
| Error | Protocol | Meaning | First Check |
|---|
401 Unauthorized | REST | No valid token present | Is Authorization: Bearer <token> header set? |
403 Forbidden | REST | Valid token, insufficient permissions | Does token have required scopes? |
UNAUTHENTICATED | GraphQL | Token missing, invalid, or expired | Is token valid? Test with activeUser query |
FORBIDDEN | GraphQL | Token valid but lacks required scope or role | Check scope table below |
NOT_FOUND | GraphQL | Resource missing OR user lacks all access | Verify resource exists with an admin account |
Scope Requirements Table
| Operation | Required Scope | Required Role |
|---|
| Read projects/models/versions | streams:read | Any project role |
| Create/modify/delete projects | streams:write | Contributor or Owner |
| Delete a project | streams:write | Owner only |
| Read user profile | profile:read | — |
| Read user email | profile:email | — |
| Update user profile | profile:write | — |
| List API tokens | tokens:read | — |
| Create/revoke API tokens | tokens:write | — |
| Search/list users | users:read | — |
| Server admin operations | server:setup | SERVER_ADMIN |
401 vs 403: The Critical Distinction
401 Unauthorized (UNAUTHENTICATED)
The server does NOT recognize the caller as a valid user.
Causes (in order of likelihood):
- Missing Authorization header — The request has no
Authorization header at all
- Malformed token — Extra whitespace, newline characters, or truncated string
- Expired token — OAuth access tokens have a limited lifetime
- Revoked token — Admin or user revoked the token
- Wrong server — Token was generated for
app.speckle.systems but sent to a self-hosted instance (or vice versa)
Diagnostic steps:
query { activeUser { id name } }
- If result is
null → token is invalid or missing profile:read scope
- If result contains user data → token is valid; problem is elsewhere
from specklepy.api.client import SpeckleClient
client = SpeckleClient(host="https://app.speckle.systems")
client.authenticate_with_token("YOUR_TOKEN")
user = client.active_user.get()
print(f"Authenticated as: {user.name}")
403 Forbidden (FORBIDDEN)
The server recognizes the caller but DENIES the requested operation.
Causes (in order of likelihood):
- Missing scope on token — Token was created without the required scope
- Insufficient project role — User is viewer but operation requires contributor/owner
- Resource-scoped token — Token is limited to specific projects; current project is not in the allowed list
- Server role restriction — Operation requires SERVER_ADMIN but user is SERVER_USER
- Workspace restriction — Project is in a workspace where user has no membership
Diagnostic steps:
query { serverInfo { scopes { name description } } }
query { project(id: "<projectId>") { role } }
query {
project(id: "<projectId>") {
permissions {
canLoad { authorized code message }
canCreateModel { authorized code message }
canDelete { authorized code message }
canPublish { authorized code message }
}
}
}
Personal Access Tokens (PATs)
Common PAT Errors
Error: "UNAUTHENTICATED" with a PAT that previously worked
Symptom: A PAT that was working returns UNAUTHENTICATED.
Cause: PAT was revoked (by user or admin), or the Speckle server was reinstalled/migrated and tokens were invalidated.
Fix:
- Go to Profile > Settings > Developer > Access Tokens
- Check if the token still exists in the list
- If not, generate a new token with the required scopes
- Update the token in your environment variables or secret manager
Error: "FORBIDDEN" on streams:write operation with a PAT
Symptom: Token authenticates successfully but write operations fail.
Cause: PAT was created with only streams:read scope.
Fix:
- Check the scopes assigned to your token (you cannot inspect scopes of an existing PAT after creation)
- ALWAYS create a new PAT with the correct scopes — you CANNOT modify scopes of an existing token
- Revoke the old token
Error: Operations work on some projects but not others
Symptom: Same token works for Project A but returns FORBIDDEN for Project B.
Cause: Token was created with limitResources restricting it to specific projects or workspaces.
Fix:
- Create a new token without resource restrictions, OR
- Add the target project/workspace to the allowed resources when creating the token
mutation {
apiTokenCreate(token: {
name: "My Scoped Token"
scopes: ["streams:read", "streams:write", "profile:read"]
lifespan: 7776000
limitResources: [
{ id: "<projectId>", type: project }
{ id: "<workspaceId>", type: workspace }
]
})
}
PAT Security Rules
- NEVER embed PATs in client-side JavaScript — use OAuth2 flow for browser apps
- NEVER commit tokens to version control — use environment variables
- NEVER create tokens with more scopes than needed — principle of least privilege
- ALWAYS set a
lifespan (in seconds) when creating tokens programmatically
- ALWAYS rotate tokens periodically and revoke unused tokens
- ALWAYS revoke tokens immediately if compromised (see Token Compromise Response below)
OAuth2 Flow Errors
Speckle implements OAuth2 Authorization Code flow with a challenge parameter (similar to PKCE).
Challenge Mismatch Error
Symptom: Token exchange (POST /auth/token) returns an error after successful user authorization.
Cause: The challenge string sent in Step 4 (token exchange) does not match the one used in Step 2 (authorization redirect).
Fix:
- Generate a cryptographically random challenge string ONCE
- Store it in
localStorage or session storage BEFORE redirecting
- Retrieve the SAME string when exchanging the access code
- NEVER regenerate the challenge between redirect and token exchange
const challenge = crypto.randomUUID();
localStorage.setItem('speckle_challenge', challenge);
window.location.href = `https://server/authn/verify/${appId}/${challenge}`;
const challenge = localStorage.getItem('speckle_challenge');
const response = await fetch('https://server/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accessCode: receivedCode,
appId: APP_ID,
appSecret: APP_SECRET,
challenge: challenge
})
});
Redirect URI Mismatch
Symptom: User sees an error page during authorization instead of being redirected back.
Cause: The redirect URI in the authorization request does not exactly match the URI registered for the application.
Fix:
- The redirect URI MUST be an exact string match (including trailing slashes, port numbers, protocol)
http://localhost:3000/callback does NOT match http://localhost:3000/callback/
- Register separate applications for development (
localhost) and production environments
Expired Access Code
Symptom: Token exchange fails even though the challenge matches.
Cause: The access code received in the callback has a short validity window.
Fix: Exchange the access code for tokens immediately in the callback handler. NEVER store access codes for later use.
App Secret in Client-Side Code
Symptom: Security vulnerability, not a runtime error.
Cause: App Secret embedded in frontend JavaScript is visible to anyone inspecting the page.
Fix:
- NEVER include App Secret in client-side code
- Use a backend proxy to handle token exchange
- For pure frontend apps, use the
speckle-auth community package which handles public client flow
Server vs Cloud Authentication
Endpoint Differences
| Aspect | Speckle Cloud | Self-Hosted Server |
|---|
| GraphQL endpoint | https://app.speckle.systems/graphql | https://<your-domain>/graphql |
| Auth redirect | https://app.speckle.systems/authn/verify/... | https://<your-domain>/authn/verify/... |
| Token exchange | https://app.speckle.systems/auth/token | https://<your-domain>/auth/token |
| PAT creation | Cloud UI: Settings > Developer | Server UI: Settings > Developer |
Common Cross-Server Errors
Error: Token works in browser but not in script
Symptom: User can access the Speckle frontend but API calls with their token fail.
Cause: Token was generated on a different server instance. Common when users have both cloud and self-hosted accounts.
Fix:
- Verify which server the token belongs to
- Use
serverInfo query (no auth required) to confirm you are hitting the correct server:
query { serverInfo { name canonicalUrl version } }
- Generate a token on the correct server
Error: SSO login works but API token does not
Symptom: User authenticates via SSO (Azure AD, Google, GitHub) in browser but PAT fails.
Cause: On self-hosted servers with SSO, user accounts are created through the SSO provider. PATs are still generated through the Speckle UI and are independent of SSO session tokens. If the SSO-created account is deactivated or the server enforces re-authentication, existing PATs may be invalidated.
Fix:
- Verify the user account is still active on the server
- Generate a new PAT through the Speckle UI (after SSO login)
- Check with the server admin if token-based access is restricted
Error: Self-hosted server returns unexpected auth strategy
Symptom: OAuth flow fails because the server uses a different authentication strategy.
Cause: Self-hosted servers can configure custom auth strategies (local, Azure AD, Google, GitHub, OIDC).
Fix:
query { serverInfo { authStrategies { id name icon } } }
Use the returned strategies to determine the correct authentication flow.
Refresh Token Errors
Refresh Token Expired
Symptom: POST /auth/token with refresh token returns an error.
Cause: Refresh tokens have a limited lifetime. When expired, no programmatic renewal is possible.
Fix: The user MUST re-authenticate through the full OAuth2 flow (browser redirect).
Refresh Token Already Used
Symptom: Refresh succeeds once but subsequent refreshes with the same token fail.
Cause: Speckle refresh tokens are single-use. Each refresh returns a NEW access token AND a NEW refresh token.
Fix:
- ALWAYS store the new refresh token returned from each refresh operation
- NEVER reuse an old refresh token — it is invalidated after first use
const response = await fetch('https://server/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refreshToken: storedRefreshToken,
appId: APP_ID,
appSecret: APP_SECRET
})
});
const { token, refreshToken } = await response.json();
saveToken(token);
saveRefreshToken(refreshToken);
Token Compromise Response
If a token (PAT or OAuth) is compromised:
- Immediately revoke the token: Settings > Developer > Access Tokens
- Generate a replacement token with the same scopes
- Update all applications using the compromised token
- Review server access logs for unauthorized activity
- If an App Secret is exposed: regenerate the app secret and update all deployments
SpecklePy Authentication Errors
Common SpecklePy Auth Patterns
from specklepy.api.client import SpeckleClient
from specklepy.api.credentials import get_default_account
client = SpeckleClient(host="https://app.speckle.systems")
client.authenticate_with_token("your_token_here")
account = get_default_account()
client = SpeckleClient(host=account.serverInfo.url)
client.authenticate_with_account(account)
Error: "SpeckleException: Authentication failed"
Cause: Token is invalid, expired, or for a different server.
Fix:
- Verify the token is for the correct server URL
- Test the token with a simple
activeUser query
- If using
get_default_account(), ensure Speckle Manager has an active account configured
Error: "SpeckleException: No default account found"
Cause: No account is configured in Speckle Manager, or the accounts database is corrupted.
Fix:
- Open Speckle Manager and log in to your server
- Alternatively, authenticate directly with a token instead of using
get_default_account()
GraphQL Authorization Directives
Speckle uses custom GraphQL directives for fine-grained authorization:
| Directive | Purpose | Failure Code |
|---|
@hasServerRole(role: SERVER_USER) | Requires minimum server role | FORBIDDEN |
@hasScope(scope: "streams:read") | Requires token to have specific scope | FORBIDDEN |
@hasScopes(scopes: [...]) | Requires multiple scopes | FORBIDDEN |
@isOwner | Restricts to resource owner | FORBIDDEN |
When a directive check fails, the GraphQL response contains:
{
"errors": [
{
"message": "You do not have the required server role",
"extensions": { "code": "FORBIDDEN" }
}
],
"data": null
}
ALWAYS check the extensions.code field to distinguish between FORBIDDEN and UNAUTHENTICATED.
Reference Links
Official Sources