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:
// 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:
// 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:
// Production
redirectUri: 'https://yourapp.com/auth/callback'
// Development (localhost exception)
redirectUri: 'http://localhost:3000/auth/callback'❌ Wrong:
// ⚠️ 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 path5. Store Tokens Securely
Backend (Node.js/Express):
// ✅ 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):
// 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 allToken Storage
- Best: Server-side sessions with HTTP-only cookies
- Good:
localStoragewith PKCE (what the@flowsta/authSDK 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:
// ✅ 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 holdA 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
// PKCE prevents this attack
const codeChallenge = generateCodeChallenge(codeVerifier);
// Even if attacker steals the code, they can't exchange it
// without the code_verifier2. Cross-Site Request Forgery (CSRF)
Attack: Attacker tricks user into authorizing their malicious app.
Prevention:
- ✅ Always use
stateparameter - ✅ Verify state matches stored value
- ✅ Use cryptographically random state
// 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
// ✅ 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(a401means 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
// ✅ 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
Refresh Token Security
Best Practices:
- Store securely (server-side or HTTP-only cookie)
- Revoke on logout (call
/oauth/revoke) - 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.
// 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:
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
Laxto 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):
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):
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
// 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:
- User clicks "Logout" in your app
- Your app calls
/auth/logout(clears SSO cookie) - User clicks "Sign in with Flowsta"
- OAuth flow shows the sign-in screen (not auto-login)
- User approves the sign-in as the other account
- New SSO session established
Testing Multi-Account
To test multi-account switching:
- Sign in with Account A
- Logout (verify
/auth/logoutis called) - Click "Sign in with Flowsta"
- You should see the sign-in screen, not auto-login
- 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
/oauth/userinforeturnsemailandemail_verifiedonly after the user shares the address at consent - the scope alone guarantees nothing.- Only verified addresses can be shared, so an email you receive is a trustworthy claim.
- 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
// 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/userinforeturns 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:
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:
Check audit logs in developer dashboard regularly
Monitor for suspicious patterns:
- Multiple failed auth attempts
- Unusual IP addresses
- High token refresh rates
- Failed token validations
Set up alerts for:
- Rate limit exceeded
- Invalid client credentials
- Token revocation spikes
Common Mistakes to Avoid
1. Skipping State Verification
// ❌ 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
// ❌ DON'T DO THIS
document.cookie = `token=${token}`; // Not HttpOnly, accessible to XSS
window.token = token; // Global variable, accessible to any scriptFix: Use the @flowsta/auth SDK (uses localStorage with PKCE), or HTTP-only cookies on the backend.
3. Not Using PKCE
// ❌ 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
// ❌ 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
Immediately revoke all refresh tokens:
javascriptawait revokeAllUserTokens(userId);Notify users if personal data was exposed
Review audit logs for suspicious activity
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?
- 💬 Discord: Join our community
- 🆘 Support: Find out about Flowsta support options
- 🐙 GitHub: github.com/WeAreFlowsta