OAuth API Reference
Complete reference for Flowsta OAuth 2.0 endpoints.
Base URL
https://auth-api.flowsta.comAll endpoints use this base URL.
Authentication
OAuth endpoints use different authentication methods:
| Endpoint | Authentication Method |
|---|---|
/oauth/authorize | None (public) |
/oauth/token | PKCE (client_id + code_verifier), or client_secret for confidential clients |
/oauth/userinfo | Bearer token (Authorization header) |
/oauth/revoke | None (client_id + client_secret optional; checked only when both are sent) |
PKCE is Mandatory
All OAuth clients must use PKCE. Requests without code_challenge will be rejected. The @flowsta/login-button and @flowsta/auth SDKs handle this automatically.
No Client Secret Required
Flowsta uses OAuth 2.0 with PKCE, which means you don't need a client secret. PKCE provides security for browser and mobile apps without exposing secrets in frontend code.
Authorization Endpoint
GET /oauth/authorize
Initiates the OAuth flow by redirecting the user to the Flowsta login page.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
response_type | string | ✅ | Must be code |
client_id | string | ✅ | Your application's client ID |
redirect_uri | string | ✅ | One of your configured redirect URIs |
scope | string | ❌ | Space-separated scopes (see Available Scopes); defaults to openid |
state | string | ⚠️ | CSRF protection token (highly recommended); at least 8 characters when present |
code_challenge | string | ✅ | PKCE code challenge (SHA-256 of code_verifier, base64url-encoded) |
code_challenge_method | string | ✅ | Must be S256 (only supported method) |
Example Request
GET https://auth-api.flowsta.com/oauth/authorize?
response_type=code&
client_id=abc123...&
redirect_uri=https://yourapp.com/auth/callback&
scope=openid%20display_name&
state=random_state_string&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256Response
Redirect to login page:
If the user is not signed in, they're sent to login.flowsta.com, where they approve the sign-in with their Flowsta Vault (the login page has no password field - the Vault signs a challenge, see Vault Sign-In). The API appends your app's name (and logo URL, if one is registered) for the login page to show:
https://login.flowsta.com/login?
client_id=abc123...&
redirect_uri=https://yourapp.com/auth/callback&
scope=openid%20display_name&
state=random_state_string&
code_challenge=E9Melhoa...&
code_challenge_method=S256&
app_name=My+AppRedirect to callback:
After successful authentication and consent:
https://yourapp.com/auth/callback?
code=def456...&
state=random_state_stringError Responses
Validation failures are answered by the API as JSON - the browser is not sent to your redirect_uri:
// HTTP 400 Bad Request
{
"error": "invalid_request",
"error_description": "Missing required parameters: client_id, redirect_uri, response_type"
}Only access_denied reaches your redirect URI, after the user chooses Don't allow on the consent screen:
https://yourapp.com/auth/callback?
error=access_denied&
error_description=User+denied+authorization&
state=random_state_stringError Codes:
| Status | Error | Description |
|---|---|---|
| 400 | invalid_request | Missing or invalid parameters - including a redirect_uri that is not registered, a missing code_challenge, or a state shorter than 8 characters |
| 401 | invalid_client | Client ID not found or app disabled |
| 400 | unsupported_response_type | response_type is not code |
| 400 | invalid_scope | A requested scope is unknown or not allowed for this app |
| redirect | access_denied | User chose Don't allow on the consent screen |
| 500 | server_error | Internal server error |
Token Endpoint
POST /oauth/token
Exchange authorization code for access token and refresh token.
Request Headers
Content-Type: application/jsonRequest Body
Grant Type: authorization_code
| Field | Type | Required | Description |
|---|---|---|---|
grant_type | string | ✅ | Must be authorization_code |
code | string | ✅ | Authorization code from callback |
redirect_uri | string | ✅ | Must match the original redirect URI |
client_id | string | ✅ | Your application's client ID |
code_verifier | string | ✅ | PKCE code verifier |
PKCE Security
PKCE replaces client secrets for all clients. The code_verifier proves you initiated the authorization request without needing to store secrets.
Grant Type: refresh_token
| Field | Type | Required | Description |
|---|---|---|---|
grant_type | string | ✅ | Must be refresh_token |
refresh_token | string | ✅ | The refresh token |
client_id | string | ✅ | Your application's client ID |
Example Request (Authorization Code)
POST /oauth/token HTTP/1.1
Host: auth-api.flowsta.com
Content-Type: application/json
{
"grant_type": "authorization_code",
"code": "def456...",
"redirect_uri": "https://yourapp.com/auth/callback",
"client_id": "flowsta_app_abc123...",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}Example Request (Refresh Token)
POST /oauth/token HTTP/1.1
Host: auth-api.flowsta.com
Content-Type: application/json
{
"grant_type": "refresh_token",
"refresh_token": "ghi012...",
"client_id": "flowsta_app_abc123..."
}Success Response (Authorization Code Grant)
Status: 200 OK
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 86400,
"refresh_token": "ghi012...",
"scope": "openid display_name"
}| Field | Type | Description |
|---|---|---|
access_token | string | Access token (valid for 24 hours) |
token_type | string | Always Bearer |
expires_in | number | Seconds until token expires (86400 = 24 hours) |
refresh_token | string | Refresh token (valid for 30 days) |
scope | string | Granted scopes - what you requested, plus openid if you omitted it |
Success Response (Refresh Token Grant)
Status: 200 OK
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 86400,
"scope": "openid display_name"
}No Refresh Token Rotation
The refresh grant returns a new access token only - the response contains no refresh_token. Keep using your original refresh token; it expires 30 days after it was issued. When it expires, send the user through the authorization flow again.
Error Response
Status: 400 Bad Request
{
"error": "invalid_grant",
"error_description": "Authorization code is invalid, expired, or already used"
}Error Codes:
| Status | Error | Description |
|---|---|---|
| 400 | invalid_request | Missing required parameters (grant_type, client_id, code + redirect_uri, refresh_token, or a code_verifier / client_secret) |
| 401 | invalid_client | Client ID not found, app disabled, or wrong client_secret |
| 400 | invalid_grant | Authorization code invalid, expired, or already used; code issued to another client; redirect_uri mismatch; PKCE verifier mismatch; refresh token invalid, expired, or revoked; or a legacy account that must be upgraded in Flowsta Vault before it can sign in again |
| 400 | unsupported_grant_type | Only authorization_code and refresh_token are supported |
| 429 | rate_limited | More than 30 token requests in a minute from this IP + client ID (see Rate Limiting) |
| 429 | mau_limit_reached | The app has reached its free-tier limit for monthly active users |
Access Tokens Are Opaque
Treat access tokens as opaque strings. They are signed with a server-only secret (HS256), carry no iss claim, and their internal claims are not part of the public contract - your app cannot verify them locally, and there is no public key to verify against.
To validate a token or get the identity behind it, call /oauth/userinfo. A 401 means the token is invalid, expired, or revoked.
User Info Endpoint
GET /oauth/userinfo
Retrieve user profile information using an access token.
Request Headers
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Example Request
GET /oauth/userinfo HTTP/1.1
Host: auth-api.flowsta.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Success Response
Status: 200 OK
With openid display_name scopes only:
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe"
}With all profile scopes:
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe",
"preferred_username": "johndoe",
"did": "did:flowsta:uhCAk7JpEWfkiV_RdAFfCnRZcJ9PwJR4yTLN-E3EcVU7KYCnRRZc",
"agent_pub_key": "uhCAk7JpEWfkiV_RdAFfCnRZcJ9PwJR4yTLN-E3EcVU7KYCnRRZc",
"profile_picture": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0...",
"has_custom_picture": true
}Email Requires the User to Share It
Flowsta's server keeps only a hash of the user's email. The email scope asks the user to share their address at the consent screen; only then do email and email_verified appear in the response (verified addresses only). Revoking your app deletes the shared copy.
If the user didn't share one and your app needs it, ask them directly.
| Field | Type | Scope | Description |
|---|---|---|---|
sub | string (UUID) | openid | Stable unique user identifier (always included) |
name | string | display_name | User's display name |
preferred_username | string or null | username | Username (null if the user has not set one) |
did | string | did or holochain | W3C Decentralized Identifier |
agent_pub_key | string | public_key or holochain | Holochain agent public key |
profile_picture | string | profile_picture | Profile picture (base64 data URI or URL) |
has_custom_picture | boolean | profile_picture | Whether user uploaded a custom picture |
email | string | email | Email address - present only if the user shared it at consent (verified only; deleted on revoke) |
email_verified | boolean | email | Email verification status - present alongside email (always true when shared) |
Error Response
Status: 401 Unauthorized
{
"error": "invalid_token",
"error_description": "Token has expired"
}Error Codes:
| Status | Error | error_description |
|---|---|---|
| 401 | invalid_token | Missing or invalid Authorization header, Token has expired, Invalid token, Wrong token type (not an OAuth access token), or Client is no longer active (the app was disabled) |
| 404 | not_found | User not found |
Scopes never produce an error here: the response simply contains only the fields your token's scopes cover.
Token Revocation Endpoint
POST /oauth/revoke
Revoke a refresh token to logout the user.
Request Headers
Content-Type: application/jsonRequest Body
| Field | Type | Required | Description |
|---|---|---|---|
token | string | ✅ | The refresh token to revoke (only refresh tokens can be revoked; access tokens expire on their own) |
token_type_hint | string | ❌ | Accepted and recorded, not enforced - any value is treated as refresh_token |
client_id | string | ❌ | Your application's client ID; checked only when client_secret is also sent |
Example Request
POST /oauth/revoke HTTP/1.1
Host: auth-api.flowsta.com
Content-Type: application/json
{
"token": "ghi012...",
"token_type_hint": "refresh_token",
"client_id": "flowsta_app_abc123..."
}Success Response
Status: 200 OK
{
"success": true
}Error Response
Status: 400 Bad Request
{
"error": "invalid_request",
"error_description": "Missing token parameter"
}Vault Sign-In (Device-Hosted Identities)
Flowsta identities sign in without a password field. When a user signs in on login.flowsta.com, their Flowsta Vault proves possession of their keys by signing a one-time challenge - no one in between. Two endpoints implement this. Your OAuth app does not call them - they're used by Flowsta's login page and clients, and are documented here so you understand the full flow. (Attempting a password login against a device-hosted account returns 403 with error: "device_hosted_account".)
POST /auth/vault/challenge
Request a one-time sign-in challenge.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
client_id | string | ❌ | Requesting client identifier (defaults to flowsta); 1-64 characters from A-Z a-z 0-9 . _ -, else 400 invalid_client_id |
Success Response
Status: 200 OK
{
"challenge": "flowsta-auth-challenge:v1:<nonce>:<client_id>",
"expires_in": 300
}The challenge is single-use, expires after expires_in seconds, and carries a fixed domain-separation prefix so it can never be confused with any other signature the Vault produces.
POST /auth/vault/token
Exchange a Vault-signed challenge for a session.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
challenge | string | ✅ | The exact challenge string from /auth/vault/challenge |
agent_pub_key | string | ✅ | The identity's Holochain agent public key |
signature | string | ✅ | Ed25519 signature over the challenge, produced by the Vault |
Success Response
Status: 200 OK - also sets the flowsta_session SSO cookie (httpOnly, SameSite=Lax, domain .flowsta.com, 7 days), which is what lets /oauth/authorize skip the login prompt for already-signed-in users.
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": null,
"agentPubKey": "uhCAk...",
"did": "did:flowsta:uhCAk...",
"emailVerified": true,
"displayName": "John Doe",
"username": "johndoe",
"profilePicture": "data:image/svg+xml;base64,...",
"hasCustomPicture": true,
"hostingModel": "device-hosted",
"loginMethod": "vault"
}
}Note email: null - the server never holds a device-hosted identity's plaintext email; the device knows its own email.
Error Responses
| Status | Error | Description |
|---|---|---|
| 400 | invalid_input | Body failed validation (challenge, agent_pub_key, and signature are required strings) |
| 400 | invalid_challenge | Challenge doesn't carry the required prefix |
| 401 | challenge_expired | Challenge unknown, already used, or expired - request a new one |
| 400 | invalid_agent_pub_key / invalid_signature | Malformed key or signature |
| 401 | signature_verification_failed | Signature doesn't verify against the agent key |
| 401 | unknown_agent_key | No identity registered for this device key |
| 403 | account_blocked | Identity blocked - contact support |
| 403 | not_device_hosted | A legacy account that isn't device-hosted yet - signing in with the Vault app completes the upgrade |
PKCE (Proof Key for Code Exchange)
What is PKCE?
PKCE enhances OAuth security by preventing authorization code interception attacks. It's especially important for public clients (SPAs, mobile apps).
How PKCE Works
- Generate code verifier (random 43-128 character string)
- Generate code challenge (SHA256 hash of verifier, base64url encoded)
- Send code challenge with authorization request
- Send code verifier with token exchange request
- Server verifies that challenge matches verifier
Implementation
Browser, in a <script type="module"> (top-level await):
function randomString(length) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => charset[b % charset.length]).join('');
}
function base64url(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// 1. Generate code verifier (43-128 characters)
const codeVerifier = randomString(128);
// 2. Generate code challenge (SHA-256 of the verifier, base64url-encoded)
const codeChallenge = base64url(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
);
// Store the verifier for the callback page
sessionStorage.setItem('code_verifier', codeVerifier);
// Use the challenge in the authorization request
const params = new URLSearchParams({
response_type: 'code',
client_id: 'your_client_id',
redirect_uri: 'https://yourapp.com/auth/callback',
scope: 'openid display_name profile_picture',
state: randomString(32),
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
const authUrl = `https://auth-api.flowsta.com/oauth/authorize?${params}`;Button Widget Handles PKCE
The @flowsta/login-button package automatically handles PKCE for you. You don't need to implement it manually.
Rate Limiting
One endpoint is rate-limited: POST /oauth/token, at 30 requests per minute, counted per client IP address + client_id. /oauth/authorize, /oauth/userinfo, and /oauth/revoke have no request limit.
Every /oauth/token response carries the draft-6 standard headers:
RateLimit-Limit: 30
RateLimit-Remaining: 29
RateLimit-Reset: 60RateLimit-Reset is the number of seconds until the window resets, not a timestamp.
429 Too Many Requests Response:
{
"error": "rate_limited",
"message": "Too many token requests. Please slow down."
}Note the body carries message, not error_description.
Token Lifetimes
| Token Type | Lifetime | Renewable? |
|---|---|---|
| Authorization Code | 10 minutes | ❌ No |
| Access Token | 24 hours | ❌ No (use refresh token) |
| Refresh Token | 30 days | ❌ No rotation - a refresh returns a new access token only |
The refresh token's 30-day expiration is fixed from the time it was issued. When it expires, send the user through the authorization flow again - if their Flowsta SSO session is still active (and, on a computer, their Vault holds the same identity), this happens without a login prompt.
Scopes
Available Scopes
Flowsta uses granular scopes so users only consent to the specific data your app needs:
| Scope | Data Included | Description |
|---|---|---|
openid | sub | User ID (UUID) - auto-included |
display_name | name | User's display name |
username | preferred_username | User's @username |
email | email, email_verified | Email address - only if the user shared it at consent (verified only; deleted on revoke) |
profile_picture | profile_picture, has_custom_picture | Profile picture |
did | did | W3C Decentralized Identifier |
public_key | agent_pub_key | Holochain agent public key |
holochain | agent_pub_key, did | Holochain identity access (agent key and DID in /userinfo) |
sign | - | Authorizes calls to Sign It signing endpoints on the user's behalf |
verify | - | Authorizes calls to Sign It verification endpoints |
Request Only What You Need
Instead of requesting all scopes, choose only the ones your app actually needs. This builds user trust and simplifies the consent screen.
Requesting Multiple Scopes
Separate scopes with spaces:
scope=openid display_name profile_pictureURL encoded:
scope=openid%20display_name%20profile_pictureNo Partial Grants
Consent is all-or-nothing. The user either allows every scope you requested or denies the request (access_denied). A scope your app is not allowed to request is refused up front with invalid_scope, and the token's scope is exactly what you requested - plus openid if you omitted it. There is no downgrading.
Security Best Practices
1. PKCE is Required
PKCE is mandatory for all clients. Requests without code_challenge will be rejected.
// ✅ Required: With PKCE
const authUrl = buildAuthUrl({
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
// ❌ Will fail: Without PKCE
const authUrl = buildAuthUrl({});2. Always Use State Parameter
// ✅ Good: With state for CSRF protection (at least 8 characters)
const state = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('');
sessionStorage.setItem('oauth_state', state);
// ❌ Bad: Without state
const authUrl = buildAuthUrl({ /* no state */ });3. Verify State in Callback
// ✅ Good: Verify state
const callbackState = new URLSearchParams(window.location.search).get('state');
const storedState = sessionStorage.getItem('oauth_state');
if (callbackState !== storedState) {
throw new Error('Possible CSRF attack');
}4. Use HTTPS
Use HTTPS for every production redirect URI. When you register a URI, Flowsta accepts both http: and https: (so http://localhost:3000/callback works for development) and checks for an exact string match at authorization time - it does not reject a plain-http production URI for you.
// ✅ Good
redirect_uri: 'https://yourapp.com/callback'
// ❌ Bad (except localhost)
redirect_uri: 'http://yourapp.com/callback'Testing
Sign in with your own Flowsta identity (created in Flowsta Vault), using your app's real Client ID and a http://localhost:... redirect URI registered on dev.flowsta.com.
Need Help?
- 💬 Discord: Join our community
- 🆘 Support: Find out about Flowsta support options
- 🐙 GitHub: github.com/WeAreFlowsta