This document describes the security invariants maintained by the Basecamp SDK across all six implementations (Go, TypeScript, Ruby, Swift, Kotlin, Python).
All SDK implementations enforce HTTPS for API communication with specific exceptions for local development:
| Context | HTTPS Required | Localhost Exception |
|---|---|---|
| Base URLs | Yes | Yes - for local dev/testing |
| OAuth endpoints | Yes | Yes - for local OAuth testing |
| Webhook payload URLs | Yes | No - webhooks are production-only |
Localhost is defined as: localhost, any *.localhost subdomain (RFC 6761), 127.0.0.1, or the IPv6 loopback ::1 (also accepted in its bracketed URL form [::1]). Host matching is case-insensitive.
Rationale: Base URLs and OAuth endpoints may use localhost during development. Webhook payload URLs never allow localhost because webhooks are a server-to-server feature that only makes sense in production contexts.
Authorization headers are automatically stripped when HTTP redirects cross origin boundaries. This prevents credential leakage to third-party hosts.
The bearer token is attached only to requests targeting the configured base-URL origin (with a localhost carve-out for development and testing). When a caller supplies an absolute URL as the request path, its origin must match the configured base URL or the request is rejected before any network call is made — so the credential can never be sent to a foreign host.
This is enforced at two layers:
- URL-build chokepoint: the URL builder rejects absolute URLs whose origin differs from the configured base URL.
- Token-attach backstop: immediately before the
Authorizationheader is added, the request origin is re-checked, so the invariant holds even if a future code path bypasses the URL builder.
The one intentional exception is the authenticated request to the OAuth authorization endpoint — by default Launchpad's (https://launchpad.37signals.com/authorization.json). Some SDKs let callers override this endpoint, validated as HTTPS (or localhost) before the token is attached. This is the sole sanctioned cross-origin credentialed request, and it complements the redirect Authorization-stripping and same-origin pagination Link validation described above.
Link headers from paginated responses are validated for same-origin before following. This prevents:
- SSRF attacks via poisoned Link headers
- Token leakage to attacker-controlled servers
Cache keys include a hash of the authorization token to isolate cached responses per-credential. This prevents:
- Cross-user cache poisoning
- Stale responses after token refresh
| Context | Limit | Purpose |
|---|---|---|
| General responses | 50 MB | Prevent memory exhaustion from large payloads |
| Error bodies | 1 MB | Limit parsing overhead for error responses |
| OAuth token responses | 1 MB | Prevent DoS during authentication |
| Error messages | 500 chars | Prevent information leakage in logs/errors |
Error messages extracted from API responses are truncated to 500 characters before being included in exceptions. This prevents:
- Sensitive data in error messages from being logged
- Unbounded memory growth from malformed error responses
All SDK clients are safe for concurrent use after construction. Thread/goroutine safety guarantees:
ClientandAccountClientare safe for concurrent useAuthManageruses mutex protection for all credential operations- Service accessors are protected by per-AccountClient mutex
- Service accessors use nullish coalescing for atomic initialization
- Token hash computation uses promise coalescing to prevent duplicate crypto operations
- ETag cache uses Map for thread-safe (single-threaded JS) access
OauthTokenProvideruses mutex for token refresh operations- The
refreshmethod holds mutex during the entire check-and-refresh operation
BasecampClientandAccountClientare markedSendablefor Swift 6 strict concurrency- All service properties are safe for concurrent access via actor isolation
- Configuration is immutable (
letproperties onBasecampConfig)
BasecampClientis safe for concurrent use from coroutines- Ktor's
HttpClienthandles connection pooling and thread safety internally - Configuration is immutable (
valproperties onBasecampConfigdata class)
ClientandAccountClientare safe for concurrent use from threadsOAuthTokenProviderusesthreading.Lockfor token refresh operations- Service accessors are protected by per-AccountClient
threading.Lock - Configuration is immutable (frozen
dataclass)
Important: Do not modify configuration after creating a client. Configuration is captured at construction time.
Breaking Change (Go): Client.Config() now returns Config by value instead of *Config pointer. This prevents post-construction modification but may require code changes if callers expected pointer semantics.
Go, TypeScript, Ruby, Kotlin, and Python SDKs provide helper utilities for OAuth 2.0 PKCE (Proof Key for Code Exchange):
// Go
pkce, err := oauth.GeneratePKCE()
// pkce.Verifier, pkce.Challenge
state, err := oauth.GenerateState()// TypeScript
const pkce = await generatePKCE();
// pkce.verifier, pkce.challenge
const state = generateState();# Ruby
pkce = Basecamp::Oauth::Pkce.generate
# pkce[:verifier], pkce[:challenge]
state = Basecamp::Oauth::Pkce.generate_state// Kotlin
val pkce = Pkce.generate()
// pkce.verifier, pkce.challenge
val state = Pkce.generateState()# Python
from basecamp.oauth import generate_pkce, generate_state
pkce = generate_pkce()
# pkce.verifier, pkce.challenge
state = generate_state()Security properties:
- Verifiers are 43 characters (32 random bytes, base64url-encoded)
- Challenges are SHA256 hashes of verifiers (use
code_challenge_method=S256) - State parameters are 22 characters (16 random bytes) in Go/TypeScript/Ruby/Kotlin, 43 characters (32 random bytes) in Python
- All use cryptographically secure random number generators
Go, TypeScript, Ruby, and Python SDKs provide utilities to safely log HTTP requests without exposing credentials:
// Go
safeHeaders := basecamp.RedactHeaders(req.Header)
logger.Info("request", "headers", safeHeaders)// TypeScript
const safeHeaders = redactHeaders(response.headers);
console.log("Response headers:", safeHeaders);# Ruby
safe = Basecamp::Security.redact_headers(headers)
logger.info("Headers: #{safe}")# Python (internal helper — not part of public API)
from basecamp._security import redact_headers
safe = redact_headers(headers)
print(f"Headers: {safe}")Redacted headers: Authorization, Cookie, Set-Cookie, X-CSRF-Token
Retry eligibility is decided per operation, not per HTTP method. behavior-model.json classifies
all 247 operations: the 125 GETs are retryable by method, and 81 mutations are flagged
idempotent: true — all 50 PUTs, all 24 DELETEs, and 7 POSTs (CompleteTodo, PauseQuestion,
SubscribeToCardColumn, Subscribe, EnableCardColumnOnHold, CreateBookmark, PrioritizeAssignment). The other 41 POSTs are attempted exactly once. SPEC.md §7 specifies the
three-gate algorithm and the per-SDK divergences.
- Reads (GET): retried with exponential backoff on 429/503 in every SDK. (HEAD is idempotent by method too, but Ruby's transport gates on
method == :getspecifically, so a HEAD would not retry there. The API surface has no HEAD operations today, so this is theoretical.) - Naturally-idempotent mutations (PUT/DELETE) and the 7 flagged POSTs: are retried on 429/503
by Go (generated operation path), Python, TypeScript, Kotlin, and Swift. Retrying these cannot
duplicate a resource, which is why the gate is idempotency rather than "is it a mutation".
Ruby is the sole exception — its transport retries GET only.
Go's separate hand-written
pkg/basecampHTTP helper is also GET-only. - Non-idempotent POSTs: never retried on 429/503 or network failure, in any SDK — a retry could create a duplicate resource. This does not mean such a POST is always attempted exactly once: a 401 that triggers a successful token refresh replays the request once regardless of idempotency, in Ruby and both Python transports (see the 401 table below). Ruby's raw upload path is the exception —
post_raw/put_raw(attachments, campfire uploads) go throughsingle_request_raw, which raises the mapped error directly and has no refresh-and-replay branch, so those POSTs really are attempted exactly once. - Retry-After headers: respected for 429 responses.
Reactive token refresh — refresh on a 401, then retry the request once — exists in only three transports. Everywhere else the 401 is surfaced to the caller:
| SDK | 401 behavior |
|---|---|
Go — hand-written pkg/basecamp |
Refresh via AuthManager, then a single retry (client.go) |
Go — generated pkg/generated operations |
No reactive refresh. AuthTransport obtains a token proactively per request via TokenProvider.AccessToken. A 401 is not returned as an error by the *WithResponse variants: they populate response.JSON401 and return (response, nil), so a caller checking only err would read the request as successful. Check JSON401 (or the status code) explicitly. The ParseHTTPError helper is what maps a 401 to AuthError |
| Ruby | Refresh, then a single retry (http.rb) — except the raw upload path: single_request_raw (used by post_raw/put_raw for attachments and campfire uploads) raises without replaying |
| Python (sync and async) | Refresh, then a single retry (_http.py, _async_http.py) |
| TypeScript | No refresh. Generated service wrappers convert a 401 into a thrown BasecampError; the raw client does not — BasecampClient extends RawClient, so client.GET(...) resolves with { data: undefined, error } and never throws. A caller using the raw API with try/catch alone will miss authentication failures |
| Kotlin | No refresh; raised as BasecampException.Auth |
| Swift | No refresh; raised as BasecampError.auth. The transport re-runs its auth strategy before each retry, but 401 is not a retryable status, so that path is never reached for a 401 |
If you use TypeScript, Kotlin, or Swift, supply a token provider that refreshes proactively, or handle the 401 and retry at the application layer.
If you discover a security vulnerability, please report it through Basecamp's security page or email security@basecamp.com rather than opening a public issue. You can also use GitHub Security Advisories to report privately.