Skip to content

Security Best Practices ​

Learn how to implement "Sign in with Flowsta" securely and protect your users.

Overview ​

OAuth 2.0 is secure when implemented correctly. This guide covers best practices, common pitfalls, and security patterns for Sign in with Flowsta.

A note on what you're integrating with: Flowsta identities are device-hosted. Users hold their own keys, backed by a 24-word recovery phrase and the Flowsta Vault app, and approve every sign-in by signing a challenge - no password is entered on the login page, and no one is in between. Your side of the integration is standard OAuth 2.0 hygiene, covered below.

Critical Security Measures ​

1. Always Use PKCE ​

Proof Key for Code Exchange (PKCE) prevents authorization code interception attacks.

Required for Public Clients

If your application runs in the browser (SPA) or on mobile devices, PKCE is mandatory. Without it, attackers can steal authorization codes.

How PKCE Works (browser, in a <script type="module">):

javascript
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 a random code verifier (43-128 characters)
const codeVerifier = randomString(128);

// 2. Create the code challenge: SHA-256 of the verifier, base64url-encoded
const codeChallenge = base64url(
  await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
);

// 3. Store the verifier for the callback page
sessionStorage.setItem('code_verifier', codeVerifier);

// 4. Send 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',
  state: randomString(32),
  code_challenge: codeChallenge,
  code_challenge_method: 'S256',
});
const authUrl = `https://auth-api.flowsta.com/oauth/authorize?${params}`;

// 5. On the callback page, send the verifier with the token exchange:
//    POST /oauth/token { grant_type, code, redirect_uri, client_id, code_verifier }

Automatic PKCE

The @flowsta/login-button package handles PKCE automatically. You don't need to implement it yourself.

2. Implement CSRF Protection with State ​

The state parameter prevents Cross-Site Request Forgery (CSRF) attacks.

Implementation:

javascript
// Login page: generate and store state (Flowsta requires 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);
// ...include `state` in the authorization request parameters

// Callback page: verify it before exchanging the code
const returned = new URLSearchParams(window.location.search).get('state');
const stored = sessionStorage.getItem('oauth_state');
sessionStorage.removeItem('oauth_state');

if (!returned || returned !== stored) {
  throw new Error('State mismatch - possible CSRF attack');
}

// Continue with the token exchange...

If your backend does the exchange, keep the state in your server session instead and compare it there - the rule is the same: generated fresh per login, compared once, never skipped.

Always Verify State

Never skip state verification. An attacker can trick a user into authorizing an app under the attacker's control.

3. Use HTTPS Everywhere ​

Use HTTPS for every production redirect URI. When you register a URI, Flowsta accepts both http: and https: (so http://localhost:3000/... works for development) and checks for an exact string match at authorization time - it does not reject a plain-http production URI for you. Keeping production on HTTPS is on you.

✅ Correct:

javascript
// Production
redirectUri: 'https://yourapp.com/auth/callback'

// Development (localhost exception)
redirectUri: 'http://localhost:3000/auth/callback'

❌ Wrong:

javascript
// ⚠️ Insecure in production
redirectUri: 'http://yourapp.com/auth/callback'

4. Validate Redirect URIs ​

Always configure exact redirect URIs in your app settings. Wildcard URIs are dangerous.

✅ Correct:

https://yourapp.com/auth/callback
https://app.yourapp.com/callback
http://localhost:3000/callback

❌ Wrong:

https://*.yourapp.com/callback  ⚠️ Wildcard allows subdomains
https://yourapp.com/*           ⚠️ Allows any path

5. Store Tokens Securely ​

Backend (Node.js/Express):

javascript
// ✅ Secure: HTTP-only cookies
res.cookie('access_token', accessToken, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
  maxAge: 86400000, // 24 hours
});

// ✅ Secure: Server-side session
req.session.accessToken = accessToken;

Frontend (SPA):

javascript
// The @flowsta/auth SDK stores tokens in localStorage
// This is acceptable for PKCE flows (no client secrets to protect)
// PKCE verifiers and state are stored in sessionStorage (cleared after use)

// If you prefer maximum security, use HTTP-only cookies on the backend
// and don't store tokens in the browser at all

Token Storage

  • Best: Server-side sessions with HTTP-only cookies
  • Good: localStorage with PKCE (what the @flowsta/auth SDK uses - acceptable since there are no client secrets)
  • Good: sessionStorage (cleared on tab close, but loses session on refresh)
  • Never: Exposed JavaScript variables or URL parameters

6. Treat Access Tokens as Opaque ​

Flowsta access tokens are signed with a server-only secret (HS256) and carry no iss claim. There is no public key to verify against, so your app cannot validate them locally - do not try to jwt.verify() them.

To validate a token or resolve the user behind it, call /oauth/userinfo:

javascript
// ✅ Correct: validate by calling userinfo
async function validateAccessToken(accessToken) {
  const res = await fetch('https://auth-api.flowsta.com/oauth/userinfo', {
    headers: { 'Authorization': `Bearer ${accessToken}` },
  });

  if (res.status === 401) {
    // Token is invalid, expired, or revoked
    return null;
  }

  return res.json(); // { sub, name, ... }
}

// ❌ Wrong: local JWT verification is impossible
// jwt.verify(token, ???) - there is no key you can hold

A 401 from /oauth/userinfo covers expiration, revocation, and forgery in one check. Cache the result briefly if you need to avoid a network call per request, and key your own sessions on the stable sub UUID.


Common Security Vulnerabilities ​

1. Authorization Code Interception ​

Attack: Attacker intercepts authorization code before it reaches your app.

Prevention:

  • ✅ Use PKCE (mandatory for public clients)
  • ✅ Short-lived codes (10 minutes)
  • ✅ One-time use codes
javascript
// PKCE prevents this attack
const codeChallenge = generateCodeChallenge(codeVerifier);

// Even if attacker steals the code, they can't exchange it
// without the code_verifier

2. Cross-Site Request Forgery (CSRF) ​

Attack: Attacker tricks user into authorizing their malicious app.

Prevention:

  • ✅ Always use state parameter
  • ✅ Verify state matches stored value
  • ✅ Use cryptographically random state
javascript
// Generate cryptographically secure state (Web Crypto)
const state = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, '0')).join('');

// Verify in callback
if (callbackState !== storedState) {
  throw new Error('CSRF attack detected');
}

3. Token Leakage ​

Attack: Access tokens exposed through browser history, logs, or referrer headers.

Prevention:

  • ✅ Use POST requests for sensitive data
  • ✅ Never include tokens in URLs
  • ✅ Clear sensitive data from browser history
javascript
// ✅ Good: Token in Authorization header
fetch('/api/user', {
  headers: {
    'Authorization': `Bearer ${accessToken}`
  }
});

// ❌ Bad: Token in URL
fetch(`/api/user?token=${accessToken}`); // Visible in logs!

4. Open Redirect Vulnerability ​

Attack: Attacker manipulates redirect_uri to send user to malicious site.

Prevention:

  • ✅ Whitelist exact redirect URIs
  • ✅ No wildcard patterns
  • ✅ Server validates redirect_uri

Flowsta automatically enforces this - you must pre-configure all redirect URIs in your app settings.

5. Insufficient Token Validation ​

Attack: Accepting expired, revoked, or forged tokens.

Prevention:

  • ✅ Validate every token by calling /oauth/userinfo (a 401 means invalid, expired, or revoked)
  • ✅ Never accept a token just because it "looks like" a JWT - you cannot verify Flowsta access tokens locally (see Treat Access Tokens as Opaque)
  • ✅ Clear your local session when validation fails
javascript
// ✅ The userinfo call IS the validation
const res = await fetch('https://auth-api.flowsta.com/oauth/userinfo', {
  headers: { 'Authorization': `Bearer ${token}` },
});

if (!res.ok) {
  clearLocalSession();
  redirectToLogin();
}

Token Management ​

Access Token Lifecycle ​

Access token lifecycle flowchart showing user login, token issuance, validity check, API access on valid token, refresh token check on expired token, token refresh or re-authentication

Refresh Token Security ​

Best Practices:

  1. Store securely (server-side or HTTP-only cookie)
  2. Revoke on logout (call /oauth/revoke)
  3. Plan for expiry - refresh tokens live 30 days from issue and are not rotated: the refresh grant returns a new access token only, no new refresh token. When the refresh token expires, send the user back through the authorization flow.
javascript
// Refresh access token
async function refreshAccessToken(refreshToken) {
  const response = await fetch('https://auth-api.flowsta.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: process.env.FLOWSTA_CLIENT_ID,
    }),
  });

  // Response contains access_token but NO new refresh_token -
  // keep using the original refresh token until its 30-day expiry.
  const { access_token } = await response.json();

  return { accessToken: access_token };
}

Token Revocation ​

Always revoke tokens on logout:

javascript
async function logout(refreshToken) {
  // Revoke refresh token
  await fetch('https://auth-api.flowsta.com/oauth/revoke', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      token: refreshToken,
      token_type_hint: 'refresh_token',
      client_id: process.env.FLOWSTA_CLIENT_ID,
    }),
  });

  // Clear local tokens
  req.session.destroy();
  res.clearCookie('access_token');
  res.clearCookie('refresh_token');
}

SSO Session Management ​

Flowsta uses HTTP-only cookies to enable seamless Single Sign-On (SSO) across all applications.

How SSO Sessions Work ​

When a user signs into Flowsta (their Vault signs a login challenge - directly or as part of an OAuth flow), a secure flowsta_session cookie is created:

  • HTTP-only: Cannot be accessed by JavaScript (protects against XSS)
  • Secure: Only transmitted over HTTPS in production
  • SameSite: Set to Lax to prevent CSRF attacks
  • Domain: .flowsta.com (shared across all Flowsta apps)
  • Duration: 7 days

This cookie lets users authenticate across multiple apps without approving a new sign-in each time - with the Vault having the final say on a computer:

User Flow:
1. User signs into App A → flowsta_session cookie created
2. User visits App B → the OAuth flow finds the session cookie
3. On a computer, the consent page checks the session against the identity
   in the user's Vault. Same identity (or no Vault reachable): App B's
   consent screen appears with no new sign-in. Different identity: the
   sign-in runs again as the identity the Vault holds.

Implementing Proper Logout ​

Your logout ends your app's session. It does not end the user's Flowsta session, and it does not need to:

  • Revoke the refresh token you hold (POST /oauth/revoke), then clear your own tokens, cookies, or server session.
  • POST /auth/logout is the endpoint that clears the flowsta_session cookie. It needs a Flowsta session token - the one Flowsta's own pages hold - and answers 401 to an OAuth access token, so a third-party app cannot call it.
  • logout() in @flowsta/auth clears the SDK's local state (three localStorage keys) and calls no endpoint.
  • Sign-out is per site. While the user's Flowsta session is valid, their next "Sign in with Flowsta" on your site may complete without a new prompt - as with any single sign-on. Which identity that sign-in belongs to is decided in their Vault (see Switching Identities).

Correct Logout Implementation ​

Backend session (Node.js/Express):

javascript
app.post('/logout', async (req, res) => {
  // 1. Revoke the refresh token you hold
  if (req.session.refreshToken) {
    await fetch('https://auth-api.flowsta.com/oauth/revoke', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        token: req.session.refreshToken,
        token_type_hint: 'refresh_token',
      }),
    });
  }

  // 2. Clear your own session
  req.session.destroy(() => {
    res.clearCookie('access_token');
    res.redirect('/');
  });
});

Browser app using @flowsta/auth:

javascript
import { FlowstaAuth } from '@flowsta/auth';

const auth = new FlowstaAuth({
  clientId: 'your_client_id',
  redirectUri: 'https://yourapp.com/auth/callback',
});

async function handleLogout() {
  // 1. Revoke the refresh token the SDK stored (the SDK never uses it itself)
  const refreshToken = localStorage.getItem('flowsta_refresh_token');
  if (refreshToken) {
    await fetch('https://auth-api.flowsta.com/oauth/revoke', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token: refreshToken, token_type_hint: 'refresh_token' }),
    }).catch(() => {});
  }

  // 2. Clear the SDK's local session (localStorage only - no endpoint is called)
  auth.logout();

  // 3. Redirect
  window.location.href = '/';
}

SSO Cookie Scope

/oauth/revoke revokes your app's refresh token; the user stays signed in to Flowsta. That is expected for an OAuth app. The flowsta_session cookie belongs to the user and is cleared only by Flowsta's own pages (POST /auth/logout with a Flowsta session token) or by its 7-day expiry.

Session Expiration ​

The SSO session cookie expires after 7 days. After that, the next "Sign in with Flowsta" asks for the Vault's approval again.

Your app's access token is separate: it expires after 24 hours whatever the state of the Flowsta session. A 401 from /oauth/userinfo means your token is invalid, expired, or revoked - refresh it with the refresh token or send the user through the flow again:

javascript
// Detect an expired or revoked access token
fetch('https://auth-api.flowsta.com/oauth/userinfo', {
  headers: { 'Authorization': `Bearer ${accessToken}` }
})
.then(res => {
  if (res.status === 401) {
    // Token is done - clear local tokens, then refresh or sign in again
    clearLocalTokens();
    redirectToLogin();
  }
});

Switching Identities ​

A web session belongs to the identity that signed in. Switching identity in the Vault does not end or move it - your app keeps the session it has. The next sign-in follows the Vault: on a computer, when the user clicks "Sign in with Flowsta" again, the login page signs them in as the identity the Vault holds now (unlocking or opening it first if needed). On phones, and in browsers that cannot reach a Vault, the remembered Flowsta session stands.

So there is nothing for your app to call:

  1. Key everything on sub - a new sign-in may be a different person.
  2. Sign-out is per site - your logout clears your session and revokes your refresh token, nothing more.
  3. A page that can reach the Vault may notice the switch and offer to sign in again - never switch automatically.
typescript
import { FlowstaAuth, agentKeysMatch } from '@flowsta/auth';

const auth = new FlowstaAuth({
  clientId: 'your_client_id',
  redirectUri: 'https://yourapp.com/auth/callback',
  scopes: ['openid', 'display_name', 'public_key'], // public_key (or holochain) fills user.agentPubKey
});

async function offerSwitchIfVaultDiffers() {
  const vault = await auth.detectVault();
  const user = auth.getUser();

  if (
    vault.running && vault.agentPubKey && user?.agentPubKey &&
    agentKeysMatch(user.agentPubKey, vault.agentPubKey) === false
  ) {
    // The Vault holds a different identity than this session.
    // Show a button: "Sign in as the identity in your Vault" -> auth.login()
  }
}

Testing identity switching

  1. Sign in to your app as identity A.
  2. In the Vault, switch to identity B. Your app still shows A - the session belongs to A.
  3. Click "Sign in with Flowsta" again. The login page signs in as B, and your app receives B's sub.

Email and Device-Hosted Identities ​

Flowsta identities are device-hosted: identity lives on the user's device, and the server stores only a hash of the user's email - Flowsta has no email database.

The email scope therefore works by asking the user to share their address. On a computer the user's Flowsta Vault shows its own "share your email" dialog and nothing is typed. On phones, and in browsers that cannot reach a Vault, the user types the address at the consent screen and Flowsta checks it against the hash it holds. Only verified addresses can be shared, and the shared copy is held on your app's grant only.

What This Means for Your App ​

  1. /oauth/userinfo returns email and email_verified only after the user shares the address at consent - the scope alone guarantees nothing.
  2. Only verified addresses can be shared, so an email you receive is a trustworthy claim.
  3. When the user revokes your app, the shared copy is deleted - the email disappears from /oauth/userinfo. Treat it as revocable data, not a permanent attribute.

Implementation ​

javascript
// Request the email scope only if your app genuinely needs an address -
// the user is asked to share it explicitly at the consent screen.
const authUrl = buildAuthUrl({
  scopes: ['openid', 'display_name', 'email'],
  // ...
});

// Handle the case where no email was shared (or it was later revoked)
const userInfo = await fetchUserInfo(accessToken);

if (!userInfo.email) {
  // The user chose not to share, or revoked it - ask them yourself
  showEmailCollectionForm();
}

Design Email-Optional

Key your accounts on the stable sub UUID and let email be an enhancement, not a requirement. An address shared through Flowsta is verified and revocable; an address you collect yourself is yours to verify and keep.


Production Checklist ​

Before Deploying ​

  • [ ] PKCE implemented for all clients
  • [ ] State parameter used for CSRF protection
  • [ ] HTTPS used for all redirect URIs
  • [ ] Redirect URIs whitelisted in app settings
  • [ ] Tokens stored in HTTP-only cookies or server-side sessions
  • [ ] Token validation via /oauth/userinfo (tokens are opaque - no local verification)
  • [ ] Refresh token handling implemented (no rotation - plan for the 30-day expiry)
  • [ ] Logout revokes your refresh token and clears your own session (sign-out is per site)
  • [ ] Identity switching tested (switch identity in the Vault, then sign in again - your app receives the new sub)
  • [ ] No email dependency - app works when /oauth/userinfo returns no email
  • [ ] Error handling doesn't leak sensitive information
  • [ ] Rate limiting respected (RateLimit-* headers on /oauth/token: 30 requests per minute per IP and client ID)
  • [ ] Activity log reviewed in the developer dashboard

Security Headers ​

Add security headers to your responses:

javascript
app.use((req, res, next) => {
  // Prevent clickjacking
  res.setHeader('X-Frame-Options', 'DENY');
  
  // Prevent MIME sniffing
  res.setHeader('X-Content-Type-Options', 'nosniff');
  
  // Enable XSS protection
  res.setHeader('X-XSS-Protection', '1; mode=block');
  
  // HTTPS only
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  
  // Content Security Policy
  res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'");
  
  next();
});

Monitoring & Alerts ​

Enable security monitoring:

  1. Review the Activity log in the developer dashboard regularly - it lists OAuth events per app (sign-ins, tokens issued and refreshed, revocations, denied consents) without IP addresses or user IDs

  2. Watch your own logs for suspicious patterns:

    • Repeated failed token exchanges or state mismatches
    • High token refresh rates
    • Failed token validations (401 from /oauth/userinfo)
  3. Set up alerts for:

    • 429 rate_limited from /oauth/token
    • invalid_client responses
    • Token revocation spikes

Common Mistakes to Avoid ​

1. Skipping State Verification ​

javascript
// ❌ DON'T DO THIS
app.get('/callback', (req, res) => {
  const { code } = req.query;
  // No state verification! Vulnerable to CSRF
  exchangeCodeForToken(code);
});

Fix: Always verify the state parameter.

2. Storing Tokens Insecurely ​

javascript
// ❌ DON'T DO THIS
document.cookie = `token=${token}`; // Not HttpOnly, accessible to XSS
window.token = token; // Global variable, accessible to any script

Fix: Use the @flowsta/auth SDK (uses localStorage with PKCE), or HTTP-only cookies on the backend.

3. Not Using PKCE ​

javascript
// ❌ DON'T DO THIS
const authUrl = buildAuthUrl({
  // No PKCE! Vulnerable to code interception
});

Fix: Always use PKCE - it's required for all Flowsta OAuth flows.

4. Ignoring Token Expiration ​

javascript
// ❌ DON'T DO THIS
const token = getStoredToken();
await api.call(token); // Might be expired!

Fix: Check expiration and refresh if needed.


Incident Response ​

If You Suspect a Breach ​

  1. Revoke every refresh token you hold - one POST /oauth/revoke per token - and clear your sessions:

    javascript
    for (const token of refreshTokensYouHold) {
      await fetch('https://auth-api.flowsta.com/oauth/revoke', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token, token_type_hint: 'refresh_token' }),
      });
    }

    Access tokens cannot be revoked; they expire within 24 hours.

  2. Notify users if personal data was exposed

  3. Review the Activity log in the developer dashboard

  4. Contact support: dev.flowsta.com/support

No Client Secrets to Regenerate

With PKCE, there are no client secrets to manage or rotate. Each OAuth flow generates a unique code_verifier per session, providing security without long-lived secrets.


Compliance & Standards ​

Standards Supported ​

  • ✅ OAuth 2.0 (RFC 6749)
  • ✅ PKCE (RFC 7636)
  • ✅ W3C DIDs (Decentralized Identifiers)

Privacy by Design ​

  • Consent-gated data - every field your app receives sits behind a scope the user approved on the consent screen
  • Device-hosted identity - users hold their own keys; there is no password database, and the server keeps only a hash of each email
  • Revocable email - an address is shared only on explicit consent and deleted from your app's grant when the user revokes it
  • Data portability - DIDs are portable and user-owned

Additional Resources ​

Official Specifications ​

Security Guides ​


Need Help? ​

Documentation licensed under CC BY-SA 4.0.