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 accounts 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 - there are no passwords anywhere in the flow, and no one 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:

javascript
// 1. Generate random code verifier
const codeVerifier = crypto.randomBytes(32).toString('base64url');

// 2. Create SHA256 hash (code challenge)
const codeChallenge = crypto
  .createHash('sha256')
  .update(codeVerifier)
  .digest('base64url');

// 3. Store verifier securely
sessionStorage.setItem('code_verifier', codeVerifier);

// 4. Send challenge in authorization request
const authUrl = `https://auth-api.flowsta.com/oauth/authorize?
  code_challenge=${codeChallenge}&
  code_challenge_method=S256&
  ...other params`;

// 5. Send verifier in token exchange (backend)
POST /oauth/token
{
  "code": "...",
  "code_verifier": codeVerifier
}

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
// Frontend: Generate and store state
const state = crypto.randomBytes(16).toString('hex');
sessionStorage.setItem('oauth_state', state);

// Include in authorization request
const authUrl = `https://auth-api.flowsta.com/oauth/authorize?
  state=${state}&
  ...other params`;

// Backend: Verify state in callback
app.get('/auth/callback', (req, res) => {
  const { state, code } = req.query;
  const storedState = req.session.oauthState;

  if (!state || state !== storedState) {
    return res.status(403).send('Possible CSRF attack detected');
  }

  // Continue with token exchange...
});

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

All redirect URIs must use HTTPS in production (except localhost for development).

✅ 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
const state = crypto.randomBytes(32).toString('hex');

// 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 enables users to seamlessly authenticate across multiple apps without approving a new sign-in each time:

User Flow:
1. User signs into App A → flowsta_session cookie created
2. User visits App B → OAuth flow detects session cookie
3. App B auto-authorizes → No sign-in prompt needed ✨

Implementing Proper Logout

Critical: When implementing logout in your app, you must clear both local tokens AND the SSO session cookie.

If you only clear local storage, the SSO cookie remains active, causing unexpected behavior:

  • User clicks "logout"
  • User tries to login again
  • OAuth flow auto-logs them in with the same account
  • User cannot switch accounts!

Correct Logout Implementation

For Flowsta-hosted apps (using our SDK):

javascript
import { flowstaAuth } from '~/lib/flowsta-auth';

async function handleLogout() {
  // This automatically:
  // 1. Calls /auth/logout API endpoint (clears SSO cookie)
  // 2. Clears localStorage tokens
  // 3. Disconnects Holochain
  await flowstaAuth.logout();
  
  // Redirect to home
  window.location.href = '/';
}

For third-party apps (manual implementation):

javascript
async function handleLogout() {
  const accessToken = getAccessToken(); // Your stored token
  
  try {
    // 1. Revoke OAuth 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: 'your_client_id',
      }),
    });
    
    // 2. Clear SSO session (if using first-party Flowsta auth)
    // Note: This only works if your app uses Flowsta's auth API directly
    await fetch('https://auth-api.flowsta.com/auth/logout', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
      },
    });
  } catch (error) {
    console.error('Logout error:', error);
    // Continue with local logout even if API calls fail
  }
  
  // 3. Clear all local tokens
  localStorage.removeItem('access_token');
  localStorage.removeItem('refresh_token');
  sessionStorage.clear();
  
  // 4. Redirect
  window.location.href = '/';
}

SSO Cookie Scope

The SSO session cookie is only cleared when calling /auth/logout. If you only call /oauth/revoke, the SSO cookie remains active.

For third-party apps using OAuth, this is expected behavior - the user stays logged into Flowsta but your app's access is revoked.

Session Expiration

The SSO session cookie expires after 7 days. After expiration:

  • The user approves a new sign-in with their Flowsta Vault
  • OAuth flows will show the sign-in prompt again
  • Local tokens should be cleared when detecting an expired session
javascript
// Detect expired session
fetch('https://auth-api.flowsta.com/oauth/userinfo', {
  headers: { 'Authorization': `Bearer ${accessToken}` }
})
.then(res => {
  if (res.status === 401) {
    // Session expired - clear local tokens
    clearLocalTokens();
    redirectToLogin();
  }
});

Multi-Account Switching

Users can explicitly logout and sign in with a different Flowsta account:

  1. User clicks "Logout" in your app
  2. Your app calls /auth/logout (clears SSO cookie)
  3. User clicks "Sign in with Flowsta"
  4. OAuth flow shows the sign-in screen (not auto-login)
  5. User approves the sign-in as the other account
  6. New SSO session established

Testing Multi-Account

To test multi-account switching:

  1. Sign in with Account A
  2. Logout (verify /auth/logout is called)
  3. Click "Sign in with Flowsta"
  4. You should see the sign-in screen, not auto-login
  5. Approve the sign-in as Account B

Email and Device-Hosted Accounts

Flowsta accounts 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. At the consent screen, the user explicitly provides the email to share (pre-filled from their Vault when available). Flowsta verifies it against the account's stored hash and requires the address to be verified before accepting it, then holds that shared copy 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 refresh tokens and clears SSO session
  • [ ] Multi-account switching tested (logout → sign in with different account)
  • [ ] No email dependency - app works when /oauth/userinfo returns no email
  • [ ] Error handling doesn't leak sensitive information
  • [ ] Rate limiting respected (check X-RateLimit-* headers)
  • [ ] Audit logging enabled in 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. Check audit logs in developer dashboard regularly

  2. Monitor for suspicious patterns:

    • Multiple failed auth attempts
    • Unusual IP addresses
    • High token refresh rates
    • Failed token validations
  3. Set up alerts for:

    • Rate limit exceeded
    • Invalid client credentials
    • 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. Immediately revoke all refresh tokens:

    javascript
    await revokeAllUserTokens(userId);
  2. Notify users if personal data was exposed

  3. Review audit logs for suspicious activity

  4. Contact support: security@flowsta.com

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 Compliance

  • GDPR compliant - User data stored with consent
  • Device-hosted identity - users hold their own keys; there is no password database, and the server keeps only a hash of each email
  • Right to be forgotten - Users can delete their accounts
  • Data portability - DIDs are portable and user-owned

Additional Resources

Official Specifications

Security Guides


Need Help?

Documentation licensed under CC BY-SA 4.0.