Login Button Widget
The @flowsta/login-button package provides beautiful, pre-styled "Sign in with Flowsta" buttons for all major frameworks.
Installation
npm install @flowsta/login-buttonHow the Button Works
Clicking the button performs a full-page redirect to https://login.flowsta.com/login with PKCE and state parameters. The user approves the sign-in there with their Flowsta Vault, and Flowsta redirects the browser back to your redirectUri with ?code=...&state=... - a fresh page load.
That means:
- There is no success callback. The page that rendered the button is gone by the time authorization completes. Handle the authorization code on a callback page at your
redirectUri(see Handling the Callback). onClickfires just before the redirect (useful for analytics or a loading state).onErrorfires only if the authorization URL could not be built - not for authorization failures, which arrive aserrorparameters on your callback page.- The button stores the PKCE
code_verifierandstateinsessionStorageautomatically; the package exports helpers to retrieve them on your callback page.
Email Scope
email is not in the default scopes. Requesting it asks the user to share their address at the consent screen: on a computer their Flowsta Vault shows its own dialog and nothing is typed; on phones and browsers that cannot reach a Vault they type it and Flowsta checks it against the hash it holds. If they share, /oauth/userinfo returns email and email_verified (verified addresses only); if they decline or later revoke your app, there is no email. Design your app to work without one.
New apps created on dev.flowsta.com start with openid and display_name only - tick email in the Permissions step (or under Edit Settings) before requesting it, or /oauth/authorize answers invalid_scope.
Supported Frameworks
| Framework | Import Path | Status |
|---|---|---|
| React | @flowsta/login-button/react | ✅ React 18 or 19 |
| Vue 3 | @flowsta/login-button/vue | ✅ Composition API |
| Qwik | @flowsta/login-button/qwik | ✅ Resumable |
| Vanilla JS | @flowsta/login-button/vanilla | ✅ Framework-agnostic |
Button Variants
The widget comes with 6 pre-designed variants:
| Variant | Description | Best For |
|---|---|---|
dark-pill | Dark background, pill shape | Light backgrounds |
dark-rectangle | Dark background, rectangle | Light backgrounds, formal UI |
light-pill | White background, pill shape | Dark backgrounds |
light-rectangle | White background, rectangle | Dark backgrounds, formal UI |
neutral-pill | Gray background, pill shape | Any background |
neutral-rectangle | Gray background, rectangle | Any background |
Visual Preview
Top row: dark-pill, dark-rectangle, neutral-pill, neutral-rectangle. Bottom row (on a dark background): light-pill, light-rectangle. Downloadable SVG and PNG files are on the Button Downloads page.
React
Basic Usage
import { FlowstaLoginButton } from '@flowsta/login-button/react';
function App() {
return (
<FlowstaLoginButton
clientId="your_client_id"
redirectUri="https://yourapp.com/auth/callback"
scopes={['openid', 'display_name']}
variant="dark-pill"
onError={(error) => console.error('Could not start login:', error)}
/>
);
}2
3
4
5
6
7
8
9
10
11
12
13
Props
| Prop | Type | Required | Description |
|---|---|---|---|
clientId | string | ✅ | Your Flowsta app's client ID |
redirectUri | string | ✅ | Where the authorization code is delivered after login |
scopes | FlowstaScope[] | ❌ | Scopes array (default: ['openid', 'display_name']) |
variant | ButtonVariant | ❌ | Button style (default: 'dark-pill') |
onClick | () => void | ❌ | Called when the button is clicked, just before the redirect |
onError | (error) => void | ❌ | Called if the authorization URL could not be built |
loginUrl | string | ❌ | Flowsta login URL (default: 'https://login.flowsta.com') |
className | string | ❌ | Custom CSS class |
disabled | boolean | ❌ | Disable the button |
Full Example
// Login page - renders the button. The result arrives at /auth/callback.
import { useState } from 'react';
import { FlowstaLoginButton } from '@flowsta/login-button/react';
function LoginPage() {
const [error, setError] = useState<string | null>(null);
return (
<div className="login-page">
<h1>Welcome to My App</h1>
{error && (
<div className="error-message">
{error}
</div>
)}
<FlowstaLoginButton
clientId={import.meta.env.VITE_FLOWSTA_CLIENT_ID}
redirectUri={`${window.location.origin}/auth/callback`}
scopes={['openid', 'display_name']}
variant="dark-pill"
onClick={() => setError(null)}
onError={(err) => setError(err.errorDescription || err.error)}
/>
</div>
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Callback page - route this at /auth/callback
import { useEffect, useState } from 'react';
import {
handleCallback,
validateState,
retrieveCodeVerifier,
} from '@flowsta/login-button';
function AuthCallbackPage() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
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');
const codeVerifier = retrieveCodeVerifier(state);
if (!codeVerifier) throw new Error('Missing PKCE code verifier');
// Exchange on your backend (recommended) or directly via /oauth/token
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) => setError(err.message));
}, []);
if (error) return <div className="error-message">{error}</div>;
return <div>Completing sign-in...</div>;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Vue 3
Basic Usage
<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);
};
</script>
<template>
<FlowstaLoginButton
client-id="your_client_id"
redirect-uri="https://yourapp.com/auth/callback"
:scopes="['openid', 'display_name']"
variant="dark-pill"
@error="handleError"
/>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Props
| Prop | Type | Required | Description |
|---|---|---|---|
client-id | string | ✅ | Your Flowsta app's client ID |
redirect-uri | string | ✅ | Where the authorization code is delivered after login |
:scopes | FlowstaScope[] | ❌ | Scopes array (default: ['openid', 'display_name']) |
variant | string | ❌ | Button style (default: 'dark-pill') |
login-url | string | ❌ | Flowsta login URL (default: 'https://login.flowsta.com') |
class-name | string | ❌ | Custom CSS class |
:disabled | boolean | ❌ | Disable the button |
Events
| Event | Payload | Description |
|---|---|---|
@click | - | Emitted when the button is clicked, just before the redirect |
@error | { error: string, errorDescription?: string } | Emitted if the authorization URL could not be built |
Full Example
<!-- Login page - renders the button. The result arrives at /auth/callback. -->
<script setup lang="ts">
import { ref } from 'vue';
import { FlowstaLoginButton } from '@flowsta/login-button/vue';
const error = ref<string | null>(null);
const handleError = (err: { error: string; errorDescription?: string }) => {
error.value = err.errorDescription || err.error;
};
</script>
<template>
<div class="login-page">
<h1>Welcome to My App</h1>
<div v-if="error" class="error-message">
{{ error }}
</div>
<FlowstaLoginButton
client-id="your_client_id"
redirect-uri="https://yourapp.com/auth/callback"
:scopes="['openid', 'display_name']"
variant="dark-pill"
@click="error = null"
@error="handleError"
/>
</div>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!-- Callback page - route this at /auth/callback -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import {
handleCallback,
validateState,
retrieveCodeVerifier,
} from '@flowsta/login-button';
const error = ref<string | null>(null);
onMounted(async () => {
try {
const { code, state, error: authError, errorDescription } = handleCallback();
if (authError) throw new Error(errorDescription || authError);
if (!code) throw new Error('No authorization code received');
if (!state || !validateState(state)) throw new Error('State mismatch');
const codeVerifier = retrieveCodeVerifier(state);
if (!codeVerifier) throw new Error('Missing PKCE code verifier');
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';
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error';
}
});
</script>
<template>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-else>Completing sign-in...</div>
</template>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Qwik
Basic Usage
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="https://yourapp.com/auth/callback"
scopes={['openid', 'display_name']}
variant="dark-pill"
onError$={handleError}
/>
);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Props
| Prop | Type | Required | Description |
|---|---|---|---|
clientId | string | ✅ | Your Flowsta app's client ID |
redirectUri | string | ✅ | Where the authorization code is delivered after login |
scopes | FlowstaScope[] | ❌ | Scopes array (default: ['openid', 'display_name']) |
variant | ButtonVariant | ❌ | Button style (default: 'dark-pill') |
onClick$ | QRL<() => void> | ❌ | Click callback (QRL wrapped, fires just before the redirect) |
onError$ | QRL<(error) => void> | ❌ | Called if the authorization URL could not be built (QRL wrapped) |
loginUrl | string | ❌ | Flowsta login URL (default: 'https://login.flowsta.com') |
class | string | ❌ | Custom CSS class |
disabled | boolean | ❌ | Disable the button |
Qwik QRLs
Qwik requires event handlers to be wrapped with $() for lazy loading. Use onClick$ and onError$ (with dollar sign).
Full Example
// Callback route - src/routes/auth/callback/index.tsx
import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik';
import {
handleCallback,
validateState,
retrieveCodeVerifier,
} from '@flowsta/login-button';
export default component$(() => {
const error = useSignal<string | null>(null);
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(async () => {
try {
const { code, state, error: authError, errorDescription } = handleCallback();
if (authError) throw new Error(errorDescription || authError);
if (!code) throw new Error('No authorization code received');
if (!state || !validateState(state)) throw new Error('State mismatch');
const codeVerifier = retrieveCodeVerifier(state);
if (!codeVerifier) throw new Error('Missing PKCE code verifier');
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';
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error';
}
});
return (
<div class="login-page">
{error.value ? (
<div class="error-message">{error.value}</div>
) : (
<div>Completing sign-in...</div>
)}
</div>
);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Vanilla JavaScript
Basic Usage
<!DOCTYPE html>
<html>
<head>
<title>Sign in with Flowsta</title>
</head>
<body>
<div id="login-button"></div>
<script type="module">
import { initFlowstaLoginButton } from '@flowsta/login-button/vanilla';
initFlowstaLoginButton('#login-button', {
clientId: 'your_client_id',
redirectUri: 'https://yourapp.com/auth/callback.html',
scopes: ['openid', 'display_name'],
variant: 'dark-pill',
onError: (error) => {
console.error('Could not start login:', error);
},
});
</script>
</body>
</html>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Options
| Option | Type | Required | Description |
|---|---|---|---|
clientId | string | ✅ | Your Flowsta app's client ID |
redirectUri | string | ✅ | Where the authorization code is delivered after login |
scopes | FlowstaScope[] | ❌ | Scopes array (default: ['openid', 'display_name']) |
variant | string | ❌ | Button style (default: 'dark-pill') |
onClick | function | ❌ | Click callback (fires just before the redirect) |
onError | function | ❌ | Called if the authorization URL could not be built |
loginUrl | string | ❌ | Flowsta login URL (default: 'https://login.flowsta.com') |
className | string | ❌ | Custom CSS class |
disabled | boolean | ❌ | Disable the button |
Functions
import { createFlowstaLoginButton, initFlowstaLoginButton } from '@flowsta/login-button/vanilla';
// Create and append to a container (recommended)
initFlowstaLoginButton('#login-button', options);
initFlowstaLoginButton(document.getElementById('login-button'), options);
// Create a button element (manual DOM management)
const button = createFlowstaLoginButton(options);
document.body.appendChild(button);2
3
4
5
6
7
8
9
Full Example (Callback Page)
<!-- callback.html - served at your redirectUri -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Completing sign-in...</title>
</head>
<body>
<div id="status">Completing sign-in...</div>
<div id="error-message" style="display: none; color: #dc2626;"></div>
<script type="module">
import {
handleCallback,
validateState,
retrieveCodeVerifier,
} from '@flowsta/login-button';
const errorDiv = document.getElementById('error-message');
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');
const codeVerifier = retrieveCodeVerifier(state);
if (!codeVerifier) throw new Error('Missing PKCE code verifier');
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.html';
}
completeSignIn().catch((err) => {
document.getElementById('status').style.display = 'none';
errorDiv.textContent = `Sign-in failed: ${err.message}`;
errorDiv.style.display = 'block';
});
</script>
</body>
</html>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Handling the Callback
Whatever framework you use, the pattern is the same:
- Create a page at your
redirectUri. Flowsta delivers?code=...&state=...there on a fresh page load. - Parse and validate.
handleCallback()reads the URL parameters;validateState(state)checks the CSRF state the button stored;retrieveCodeVerifier(state)returns the PKCE verifier. Both helpers clear what they read fromsessionStorage, so call each once per callback. - Exchange the code. POST
code+code_verifiertohttps://auth-api.flowsta.com/oauth/token- from your backend (recommended) or directly from the browser. See the API Reference.
All three helpers are exported from the package root:
import {
handleCallback, // parse code/state/error from the callback URL
validateState, // verify the state matches what the button stored
retrieveCodeVerifier // fetch (and clear) the stored PKCE verifier
} from '@flowsta/login-button';2
3
4
5
Prefer a Full SDK?
The @flowsta/auth package wraps the whole flow - auth.login() to start (or this button widget), and auth.handleCallback() on the callback page to exchange the code and fetch the user in one call. From v2.3.1, handleCallback() completes button-initiated logins too.
Advanced Usage
PKCE and State (Automatic)
The button widget automatically handles PKCE and CSRF state for you:
- Generates a random
code_verifierandcode_challengeper login - Generates a random
stateparameter - Stores both in
sessionStorage(the verifier is keyed by state) so your callback page can retrieve them after the redirect
Dynamic Redirect URI
Use environment variables for different environments:
const redirectUri = import.meta.env.DEV
? 'http://localhost:3000/auth/callback'
: 'https://yourapp.com/auth/callback';
<FlowstaLoginButton
clientId="your_client_id"
redirectUri={redirectUri}
scopes={['openid', 'display_name']}
/>2
3
4
5
6
7
8
9
Programmatic Login
For programmatic login without a button, use the @flowsta/auth SDK instead:
import { FlowstaAuth } from '@flowsta/auth';
const auth = new FlowstaAuth({
clientId: 'your_client_id',
redirectUri: 'https://yourapp.com/auth/callback',
scopes: ['openid', 'display_name'],
});
// Redirect to Flowsta login
auth.login();
// ...and on your callback page:
const user = await auth.handleCallback();2
3
4
5
6
7
8
9
10
11
12
13
Styling
The button widget uses inline styles and doesn't require external CSS. However, you can wrap it in a container for additional styling:
.login-button-container {
display: flex;
justify-content: center;
margin: 40px 0;
}
.login-button-container button {
cursor: pointer;
transition: transform 0.2s;
}
.login-button-container button:hover {
transform: translateY(-2px);
}2
3
4
5
6
7
8
9
10
11
12
13
14
TypeScript Support
The package includes full TypeScript definitions:
import { FlowstaLoginButton, ButtonVariant } from '@flowsta/login-button/react';
interface Props {
variant?: ButtonVariant; // 'dark-pill' | 'dark-rectangle' | etc.
}
function CustomLogin({ variant = 'dark-pill' }: Props) {
return (
<FlowstaLoginButton
clientId="your_client_id"
redirectUri="https://yourapp.com/auth/callback"
scopes={['openid', 'display_name']}
variant={variant}
/>
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Browser Support
Current evergreen browsers. The button needs the Web Crypto API (crypto.subtle, for the PKCE challenge) and sessionStorage, both of which every maintained desktop and mobile browser provides. Internet Explorer is not supported.
Bundle Size
Measured on the published dist/ of 0.1.6. Every entry imports one shared chunk that inlines the six button images as data URIs (77.9 KB minified / 6.9 KB gzipped); the framework entry itself adds 1-3 KB.
| Entry | Minified | Gzipped |
|---|---|---|
| React | ~81 KB | ~8.3 KB |
| Vue | ~80 KB | ~8.1 KB |
| Qwik | ~80 KB | ~7.9 KB |
| Vanilla JS | ~80 KB | ~8.1 KB |
Framework runtimes (React, Vue, Qwik) are peer dependencies and not included.
Next Steps
- API Reference - OAuth endpoint documentation
- OAuth Overview - Complete OAuth integration guide
- Security - Best practices and security guide
Need Help?
- 💬 Discord: Join our community
- 🆘 Support: Find out about Flowsta support options
- 🐙 GitHub: github.com/WeAreFlowsta