Skip to content

Security Best Practices

Learn how to implement "Login 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 Login with Flowsta.

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'

5. 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

6. 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: 3600000, // 1 hour
});

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

Frontend (SPA):

javascript
// ⚠️ Less secure but sometimes necessary
// Use sessionStorage (clears on tab close)
sessionStorage.setItem('access_token', accessToken);

// ❌ Never use localStorage for long-term storage
localStorage.setItem('access_token', accessToken); // DON'T

Token Storage

  • Best: Server-side sessions with HTTP-only cookies
  • Good: sessionStorage (cleared on tab close)
  • Avoid: localStorage (persists across sessions)
  • Never: Exposed JavaScript variables

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:

  • ✅ Verify token signature (JWT)
  • ✅ Check expiration (exp claim)
  • ✅ Validate issuer (iss claim)
  • ✅ Check token hasn't been revoked
javascript
import jwt from 'jsonwebtoken';

function validateAccessToken(token) {
  try {
    // Verify JWT signature and expiration
    const decoded = jwt.verify(token, JWT_PUBLIC_KEY, {
      issuer: 'https://auth-api.flowsta.com',
      algorithms: ['HS256']
    });

    // Check custom claims
    if (!decoded.userId || !decoded.clientId) {
      throw new Error('Invalid token claims');
    }

    return decoded;
  } catch (error) {
    throw new Error('Token validation failed');
  }
}

Token Management

Access Token Lifecycle

mermaid
graph LR
    A[User Logs In] --> B[Issue Access Token]
    B --> C{Token Valid?}
    C -->|Yes| D[API Access]
    C -->|No| E{Has Refresh Token?}
    E -->|Yes| F[Refresh Access Token]
    E -->|No| G[Re-authenticate]
    F --> B
    D --> C

Refresh Token Security

Best Practices:

  1. Store securely (server-side or HTTP-only cookie)
  2. Rotate on use (issue new refresh token with each refresh)
  3. Revoke on logout (call /oauth/revoke)
  4. Limit lifetime (30 days max)
  5. One-time use (invalidate after exchange)
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,
    }),
  });

  const { access_token, refresh_token: newRefreshToken } = await response.json();

  // Store new refresh token (rotation)
  // Old refresh token is now invalid
  return { accessToken: access_token, refreshToken: newRefreshToken };
}

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',
      'X-Client-Id': process.env.FLOWSTA_CLIENT_ID,
    },
    body: JSON.stringify({
      token: refreshToken,
      token_type_hint: 'refresh_token',
    }),
  });

  // 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 logs in through Flowsta (either directly or via OAuth), 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 re-entering credentials:

User Flow:
1. User logs into App A → flowsta_session cookie created
2. User visits App B → OAuth flow detects session cookie
3. App B auto-authorizes → No login 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',
        'X-Client-Id': 'your_client_id',
      },
      body: JSON.stringify({
        token: refreshToken,
        token_type_hint: 'refresh_token',
      }),
    });
    
    // 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 of inactivity. After expiration:

  • User must re-authenticate with their Flowsta credentials
  • OAuth flows will prompt for login
  • 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 login with a different Flowsta account:

  1. User clicks "Logout" in your app
  2. Your app calls /auth/logout (clears SSO cookie)
  3. User clicks "Login with Flowsta"
  4. OAuth flow shows login screen (not auto-login)
  5. User enters different credentials
  6. New SSO session established

Testing Multi-Account

To test multi-account switching:

  1. Login with Account A
  2. Logout (verify /auth/logout is called)
  3. Click "Login with Flowsta"
  4. You should see the login form, not auto-login
  5. Enter Account B credentials

Email Permission System

Flowsta's zero-knowledge architecture requires special handling for email access.

How It Works

  1. User's email is encrypted on Holochain with their password
  2. Even Flowsta cannot decrypt it
  3. Apps requesting email scope need explicit user permission
  4. User must approve email access via their Flowsta account

Implementation

javascript
// Request email scope
const authUrl = buildAuthUrl({
  scope: 'profile email',
  // ...
});

// Handle case where email is not returned
const userInfo = await fetchUserInfo(accessToken);

if (!userInfo.email) {
  // User hasn't granted email permission
  showEmailPermissionPrompt();
}

Email Permission Flow

mermaid
sequenceDiagram
    participant App
    participant Flowsta
    participant User

    App->>Flowsta: Request 'profile email' scopes
    Flowsta->>User: Show consent screen (lists requested scopes)
    User->>Flowsta: Click "Authorize" (approves all scopes)
    Flowsta->>Flowsta: Grant email permission for this app
    Flowsta->>App: Authorization code
    App->>Flowsta: Exchange code for access token
    Flowsta->>App: Access token with 'profile email' scopes
    App->>Flowsta: /oauth/userinfo
    Flowsta->>App: Returns profile + email ✅

Consent Screen Grants Email

When a user clicks "Authorize" on the consent screen, email permission is granted immediately. No separate step is needed.

Optional Email

Design your app to work without email if possible. Not all users will grant email permission due to Flowsta's privacy-first approach.


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 implemented (expiration, signature, claims)
  • [ ] Refresh token rotation implemented
  • [ ] Logout revokes refresh tokens and clears SSO session
  • [ ] Multi-account switching tested (logout → login with different account)
  • [ ] 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. Using localStorage for Tokens

javascript
// ❌ DON'T DO THIS
localStorage.setItem('access_token', token);
// Persists across sessions, vulnerable to XSS

Fix: Use sessionStorage or HTTP-only cookies.

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)
  • JWT (RFC 7519)
  • W3C DIDs (Decentralized Identifiers)

Privacy Compliance

  • GDPR compliant - User data stored with consent
  • Zero-knowledge architecture - Flowsta cannot access encrypted data
  • 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.