Skip to content

Quickstart: Sign in with Flowsta

This guide will walk you through integrating "Sign in with Flowsta" into your application in under 15 minutes.

What You'll Build

A complete OAuth integration with:

  • "Sign in with Flowsta" button in your frontend
  • OAuth callback page at your redirect URI
  • Token exchange in your backend
  • User profile retrieval

Prerequisites

  • A Flowsta developer account (sign up here)
  • A web application (static HTML or modern framework)

How Users Sign In

Flowsta accounts are device-hosted: the user's identity is a keypair on their own device, backed by a 24-word recovery phrase and the Flowsta Vault app. There are no passwords. When your button redirects a user to login.flowsta.com, their Vault signs a one-time challenge to approve the sign-in - keys only they hold, no one in between. Your app just sees a standard OAuth flow.

Choose Your Integration Method

Flowsta offers multiple ways to integrate "Sign in with Flowsta" depending on your tech stack:

Best for React, Vue, Qwik, or any app with a build system.

  • ✅ Pre-built components
  • ✅ TypeScript support
  • ✅ Automatic PKCE generation
  • ✅ Framework-specific optimizations

Continue with this guide below

🌐 Vanilla JavaScript (No Build Tools)

Best for static HTML sites or simple JavaScript projects.

  • ✅ No npm required
  • ✅ Works with any (or no) framework
  • ✅ Copy-paste ready examples
  • ✅ CDN or self-hosted buttons

View Vanilla JS Guide

🎨 Direct Button Download

Best for quick prototyping or custom implementations.

  • ✅ Download button images (SVG/PNG)
  • ✅ Hotlink from CDN
  • ✅ Manual OAuth URL construction
  • ✅ Maximum flexibility

View Button Downloads


Step 1: Create Your App

1.1 Register Your Application

  1. Go to dev.flowsta.com/dashboard/apps

  2. Click "Create New App"

  3. Enter your app details:

    • Name: My Awesome App
    • Description: A cool app using Flowsta Auth
  4. Copy your Client ID:

    Client ID: flowsta_app_abc123...

No Client Secret Needed

Flowsta uses OAuth 2.0 with PKCE, which means you don't need a client secret for browser or mobile apps. Your Client ID is all you need!

1.2 Configure OAuth Settings

In your app's details page, scroll to "OAuth Settings" and click "Edit Settings":

Add Redirect URIs

Add the URLs where users will be redirected after login:

http://localhost:3000/auth/callback    (for development)
https://yourapp.com/auth/callback       (for production)

Multiple Redirect URIs

You can add up to 10 redirect URIs per app. This allows you to use different URLs for development and production.

Select Scopes

Choose which data your app needs:

  • openid - User ID (always included)
  • display_name - User's display name
  • username - User's @username
  • did - W3C Decentralized Identifier
  • public_key - Holochain agent public key
  • profile_picture - Profile picture
  • email - Asks the user to share their address at consent (verified only; revocable)
  • sign / verify - Access to Sign It endpoints

Email Scope

Flowsta stores no email for the account - the email scope asks the user to share their address at the consent screen. If they do, /oauth/userinfo returns email and email_verified for your app (verified addresses only); if they decline or later revoke your app, there is no email. Design your app to work without one.

Add Branding (Optional)

Make your consent screen look professional:

  • Logo URL: https://yourapp.com/logo.png (recommended: 200x200px)
  • Privacy Policy: https://yourapp.com/privacy
  • Terms of Service: https://yourapp.com/terms

Click "Save OAuth Settings".

Step 2: Install the Button Widget

The @flowsta/login-button package provides pre-built login buttons for React, Vue, Qwik, and Vanilla JS.

bash
npm install @flowsta/login-button

Framework Support

  • ✅ React 16.8+ (hooks)
  • ✅ Vue 3 (composition API)
  • ✅ Qwik (resumable)
  • ✅ Vanilla JavaScript (any framework or no framework)

Step 3: Add the Login Button

The button generates PKCE parameters, stores them in sessionStorage, and performs a full-page redirect to login.flowsta.com. Nothing else happens in this page's JavaScript - the authorization code arrives at your redirect URI on a fresh page load (Step 4). The only callbacks that fire are onClick (just before the redirect) and onError (if the authorization URL could not be built).

Choose your framework:

tsx
// src/components/LoginButton.tsx
import { FlowstaLoginButton } from '@flowsta/login-button/react';

export function LoginButton() {
  const handleError = (error) => {
    console.error('Could not start login:', error);
    alert(`Login failed: ${error.errorDescription || error.error}`);
  };

  return (
    <FlowstaLoginButton
      clientId="your_client_id"
      redirectUri="http://localhost:3000/auth/callback"
      scopes={['openid', 'display_name']}
      variant="dark-pill"
      onError={handleError}
    />
  );
}
vue
<!-- src/components/LoginButton.vue -->
<script setup lang="ts">
import { FlowstaLoginButton } from '@flowsta/login-button/vue';

const handleError = (error: { error: string; errorDescription?: string }) => {
  console.error('Could not start login:', error);
  alert(`Login failed: ${error.errorDescription || error.error}`);
};
</script>

<template>
  <FlowstaLoginButton
    client-id="your_client_id"
    redirect-uri="http://localhost:3000/auth/callback"
    :scopes="['openid', 'display_name']"
    variant="dark-pill"
    @error="handleError"
  />
</template>
tsx
// src/components/login-button.tsx
import { component$, $ } from '@builder.io/qwik';
import { FlowstaLoginButton } from '@flowsta/login-button/qwik';

export default component$(() => {
  const handleError = $((error: { error: string; errorDescription?: string }) => {
    console.error('Could not start login:', error);
  });

  return (
    <FlowstaLoginButton
      clientId="your_client_id"
      redirectUri="http://localhost:3000/auth/callback"
      scopes={['openid', 'display_name']}
      variant="dark-pill"
      onError$={handleError}
    />
  );
});
html
<!-- index.html -->
<div id="login-container"></div>

<script type="module">
  import { initFlowstaLoginButton } from '@flowsta/login-button/vanilla';

  initFlowstaLoginButton('#login-container', {
    clientId: 'your_client_id',
    redirectUri: 'http://localhost:3000/auth/callback',
    scopes: ['openid', 'display_name'],
    variant: 'dark-pill',

    onError: (error) => {
      console.error('Could not start login:', error);
      alert(`Login failed: ${error.errorDescription}`);
    },
  });
</script>

Step 4: Create the Callback Page

When the user approves the sign-in, Flowsta redirects the browser to your redirect URI with ?code=...&state=.... This is a fresh page load - create a page at that route which:

  1. Reads the code and state from the URL
  2. Validates the state (CSRF protection)
  3. Retrieves the PKCE code_verifier the button stored in sessionStorage
  4. Sends code + code_verifier to your backend for the token exchange
javascript
// Runs on your /auth/callback page
import {
  handleCallback,
  validateState,
  retrieveCodeVerifier,
} from '@flowsta/login-button';

async function completeSignIn() {
  const { code, state, error, errorDescription } = handleCallback();

  if (error) {
    throw new Error(errorDescription || error);
  }
  if (!code) {
    throw new Error('No authorization code received');
  }
  if (!state || !validateState(state)) {
    throw new Error('State mismatch - possible CSRF attack');
  }

  // The button stored the verifier keyed by state; this retrieves and clears it
  const codeVerifier = retrieveCodeVerifier(state);
  if (!codeVerifier) {
    throw new Error('Missing PKCE code verifier');
  }

  // Hand off to your backend for the token exchange
  const response = await fetch('/api/auth/exchange', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code, code_verifier: codeVerifier }),
  });

  if (!response.ok) throw new Error('Authentication failed');

  window.location.href = '/dashboard';
}

completeSignIn().catch((err) => {
  console.error('OAuth callback error:', err);
});

Browser-Only Apps

If you have no backend, you can do the token exchange directly from the callback page - PKCE makes this safe for public clients. See the Vanilla JS guide for a complete browser-only example.

Step 5: Exchange the Code in Your Backend

Your backend receives the code and code_verifier from the callback page, exchanges them for tokens, and creates a session.

javascript
// routes/auth.js
import express from 'express';

const router = express.Router();

router.post('/exchange', async (req, res) => {
  const { code, code_verifier } = req.body;

  if (!code || !code_verifier) {
    return res.status(400).json({ error: 'Missing code or code_verifier' });
  }

  try {
    // Exchange authorization code for tokens
    const tokenResponse = await fetch('https://auth-api.flowsta.com/oauth/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code,
        redirect_uri: 'http://localhost:3000/auth/callback',
        client_id: process.env.FLOWSTA_CLIENT_ID,
        code_verifier,  // PKCE - no client_secret needed!
      }),
    });

    if (!tokenResponse.ok) {
      const error = await tokenResponse.json();
      throw new Error(error.error || 'Token exchange failed');
    }

    const { access_token, refresh_token, expires_in } = await tokenResponse.json();

    // Fetch user profile
    const userResponse = await fetch('https://auth-api.flowsta.com/oauth/userinfo', {
      headers: {
        'Authorization': `Bearer ${access_token}`,
      },
    });

    const user = await userResponse.json();

    // Store tokens in session
    req.session.accessToken = access_token;
    req.session.refreshToken = refresh_token;
    req.session.user = user;

    res.json({ success: true });
  } catch (error) {
    console.error('OAuth exchange error:', error);
    res.status(500).json({ error: 'Authentication failed' });
  }
});

export default router;
javascript
// app/api/auth/exchange/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const { code, code_verifier } = await request.json();

  if (!code || !code_verifier) {
    return NextResponse.json({ error: 'Missing code or code_verifier' }, { status: 400 });
  }

  try {
    // Exchange code for tokens
    const tokenResponse = await fetch('https://auth-api.flowsta.com/oauth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code,
        redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
        client_id: process.env.FLOWSTA_CLIENT_ID!,
        code_verifier,  // PKCE - no client_secret needed!
      }),
    });

    const { access_token, refresh_token } = await tokenResponse.json();

    // Fetch user info
    const userResponse = await fetch('https://auth-api.flowsta.com/oauth/userinfo', {
      headers: { 'Authorization': `Bearer ${access_token}` },
    });

    const user = await userResponse.json();

    // Set secure cookies
    const response = NextResponse.json({ success: true, user });
    response.cookies.set('access_token', access_token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 60 * 60 * 24, // 24 hours (access token lifetime)
    });

    return response;
  } catch (error) {
    console.error('OAuth error:', error);
    return NextResponse.json({ error: 'auth_failed' }, { status: 500 });
  }
}

Step 6: Store Credentials Securely

Create a .env file in your project root:

bash
# .env
FLOWSTA_CLIENT_ID=your_client_id_here

Load environment variables in your app:

javascript
// Node.js
import dotenv from 'dotenv';
dotenv.config();

const clientId = process.env.FLOWSTA_CLIENT_ID;

PKCE Security

With PKCE, your Client ID can safely be in frontend code - there's no secret to protect. The code_verifier generated per-session provides security.

Step 7: Test Your Integration

7.1 Start Your App

bash
npm run dev

Visit http://localhost:3000

7.2 Click "Sign in with Flowsta"

You'll be redirected to login.flowsta.com

7.3 Sign In or Sign Up

  • Existing users: Approve the sign-in with the Flowsta Vault - the Vault signs a challenge with keys only the user holds. No password exists to enter.
  • New users: Create a Flowsta account (they'll set up the Vault and a 24-word recovery phrase)

The user sees which data your app is requesting - for example, profile information.

7.5 Approve Access

Click "Authorize" to grant access

7.6 Return to Your App

The browser lands on your callback page with an authorization code, which your backend exchanges for an access token.

7.7 You're Logged In!

Check your console logs to see the user data:

json
{
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "name": "John Doe",
  "preferred_username": "johndoe",
  "did": "did:flowsta:uhCAk7JpEWfkiV_RdAFfCnRZcJ9PwJR4yTLN-E3EcVU7KYCnRRZc",
  "agent_pub_key": "uhCAk...",
  "profile_picture": "https://...",
  "has_custom_picture": true
}

No Email in the Response?

Not a bug - email appears only when the user explicitly shared their address at the consent screen (and the email scope was requested). No sharing, no email.

Step 8: Handle User Sessions

Store User in Session

javascript
// After successful token exchange
req.session.user = {
  id: user.sub,
  name: user.name,
  username: user.preferred_username,
  did: user.did,
};

Protect Routes

javascript
// Middleware
function requireAuth(req, res, next) {
  if (!req.session.user) {
    return res.redirect('/login');
  }
  next();
}

// Protected route
app.get('/dashboard', requireAuth, (req, res) => {
  res.render('dashboard', { user: req.session.user });
});

Logout

javascript
app.post('/logout', async (req, res) => {
  // Revoke refresh token
  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',
        client_id: process.env.FLOWSTA_CLIENT_ID,
      }),
    });
  }

  // Clear session
  req.session.destroy();
  res.redirect('/');
});

Next Steps

📚 Deep Dive

🎨 Customization

  • Try different button variants: light-pill, neutral-rectangle, etc.
  • Add your app's logo to the consent screen
  • Customize redirect behavior

🔐 Production Checklist

  • [ ] Use HTTPS for all redirect URIs
  • [ ] Implement CSRF protection with state parameter (SDK handles this automatically)
  • [ ] Use secure session storage (httpOnly cookies recommended)
  • [ ] Implement token refresh logic
  • [ ] Add error handling and user feedback
  • [ ] Test with multiple user accounts
  • [ ] Monitor OAuth audit logs in developer dashboard

No Client Secrets

With PKCE, you don't need to manage client secrets! Your Client ID can safely be in frontend code. The code_verifier provides security without secrets.

Common Issues

"Invalid redirect_uri" Error

Cause: The redirect URI doesn't match any URI configured in your app settings.

Solution: Go to your app settings and add the exact redirect URI (including protocol and port).

"Invalid client" Error

Cause: Client ID is incorrect.

Solution: Double-check your Client ID in the .env file or Developer Dashboard.

"Code has expired" Error

Cause: Authorization codes expire after 10 minutes.

Solution: Ensure your backend exchanges the code immediately after receiving it.

Email Not Returned

Cause: The user didn't share their address at the consent screen (or revoked your app, which deletes the shared copy). Flowsta's server stores only a hash - an email exists in /oauth/userinfo only after the user explicitly shares it.

Solution: Don't require the email field. If the user didn't share one and your app needs it, ask them directly after sign-in.

Need Help?

Documentation licensed under CC BY-SA 4.0.