Auth
Set up authentication with built-in email/password, GitHub, Google, or an external provider.
// App.tsx
import { useAuth } from 'valet-dev/react'
import { ValetProvider } from './_generated/valet/react'
function App() {
return (
<ValetProvider url={process.env.VALET_PROJECT_URL!} auth>
<AuthGate />
</ValetProvider>
)
}
function AuthGate() {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) return <div>Loading...</div>
if (!isAuthenticated) return <LoginForm />
return <TodoApp />
}Valet supports three approaches to authentication. Pick the one that fits your app:
| Approach | When to use | What you need |
|---|---|---|
| OAuth only | Your app signs in with GitHub, Google, or both | Client IDs + secrets from the provider |
| Email and password | Your app has its own sign-up form with email/password | Nothing beyond Valet |
| External provider | You already use Firebase, Clerk, or another auth service | Your existing auth setup |
You can also combine OAuth and email/password — they share the same setup and user table, and accounts with matching emails are linked automatically.
Common setup
Every built-in auth approach (OAuth and email/password) shares these steps. If you're using an external provider, skip to External auth providers.
1. Add auth tables to your schema
Run auth init to add the built-in auth tables to your project:
# terminal
bunx valet-dev auth initTo include OAuth providers from the start, pass them with --provider:
# terminal
bunx valet-dev auth init --provider github,googleThis adds authTables to your schema.ts and creates an auth.ts file with a defineAuth configuration. Without --provider, auth.ts defaults to email/password only (empty providers list). If you prefer to do it manually, add the authTables import and spread it into your schema:
// valet/schema.ts
import { defineSchema, defineTable, v, authTables } from 'valet-dev/server'
export default defineSchema({
...authTables,
todos: defineTable({
title: v.string(),
userId: v.string(),
}).sync({
filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
mode: 'full',
}),
})This adds four server-only tables (never synced to clients):
| Table | Purpose |
|---|---|
_auth_users | User profiles (email, name, email_verified) |
_auth_accounts | OAuth provider connections and password hashes |
_auth_sessions | Active sessions with expiry tracking |
_auth_verification_codes | Email verification and password reset codes |
2. Set the auth secret
Set VALET_AUTH_SECRET as a server-side environment variable. This secret signs and verifies JWT tokens:
# terminal
bunx valet-dev env set VALET_AUTH_SECRET=$(openssl rand -base64 32)Without VALET_AUTH_SECRET, the server rejects all auth tokens by default. During development, VALET_AUTH_SECRET is still required for built-in auth to work — the auth init next steps will remind you to set it.
3. Wrap your app in ValetProvider with auth
Pass the auth prop to ValetProvider to enable built-in auth. This handles sign-in state, token management, and WebSocket authentication in a single component:
// App.tsx
import { useAuth } from 'valet-dev/react'
import { ValetProvider } from './_generated/valet/react'
function App() {
return (
<ValetProvider url={process.env.VALET_PROJECT_URL!} auth>
<AuthGate />
</ValetProvider>
)
}
function AuthGate() {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) return <div>Loading...</div>
if (!isAuthenticated) return <LoginForm />
return <MyApp />
}The auth prop automatically wires up token management — the WebSocket connection waits for auth to resolve before connecting. You can also pass auth={{ storage: customStorage }} for custom token storage.
Advanced: manual provider nesting
If you need more control over the auth and sync lifecycle (e.g. different URLs for auth and sync), you can nest ValetAuthProvider and ValetProvider manually:
import { ValetAuthProvider, useAuth } from 'valet-dev/react'
import { ValetProvider } from './_generated/valet/react'
function App() {
return (
<ValetAuthProvider url="http://localhost:3000">
<AuthGate />
</ValetAuthProvider>
)
}
function AuthGate() {
const { isAuthenticated, isLoading, authClient } = useAuth()
if (isLoading) return <div>Loading...</div>
if (!isAuthenticated) return <LoginForm />
return (
<ValetProvider
url="ws://localhost:3000/ws"
authToken={authClient.getTokenProvider()}
>
<MyApp />
</ValetProvider>
)
}authClient.getTokenProvider() returns a function that provides the current JWT to ValetProvider for WebSocket authentication.
After completing these steps, continue with one or both of the sign-in methods below.
OAuth (GitHub, Google)
Use OAuth if you want users to sign in with their existing GitHub or Google account. No password form needed — just a button.
How OAuth works
When a user clicks "Continue with GitHub", the flow is:
- Client → Valet server: The client generates a state token for CSRF protection and redirects to your Valet server's
/auth/oauth/github/authorizeendpoint. - Valet server → GitHub: The server redirects to GitHub's authorization page.
- GitHub → Valet server: After the user authorizes, GitHub redirects back to your Valet server's callback endpoint (configured in your GitHub OAuth app settings).
- Valet server → Client: The server exchanges the code for tokens, creates or links the user account, and redirects to
VALET_SITE_URLwith credentials and the state token in the URL fragment. - Client verifies: The client checks that the state token matches, then stores the credentials.
You need to tell the server where your client app lives so it can redirect back after the flow completes:
# terminal — set this to your client app's URL
bunx valet-dev env set VALET_SITE_URL=http://localhost:8081For production, set it to your deployed frontend URL (e.g., https://myapp.com).
GitHub
-
Create a GitHub OAuth App at github.com/settings/developers.
-
Set the Authorization callback URL to your Valet server's callback endpoint:
https://your-project.fly.valet.host/auth/oauth/github/callbackThis is your Valet server URL (the same one in
VALET_PROJECT_URL), not your client app URL. -
Set the client credentials on the server:
# terminal bunx valet-dev env set GITHUB_CLIENT_ID=your_client_id bunx valet-dev env set GITHUB_CLIENT_SECRET=your_client_secretvalet-dev env setpushes secrets directly to the server's encrypted env store — you don't need to add them to a local.envfile. The server reads from the env store first, then falls back to process environment variables. -
Add a sign-in button:
import { useAuth } from 'valet-dev/react' function LoginForm() { const { signInWithOAuth } = useAuth() return ( <button onClick={() => signInWithOAuth('github')}> Continue with GitHub </button> ) }
The user is redirected to GitHub, authorizes your app, and is redirected back signed in. ValetAuthProvider handles the callback automatically.
-
Create OAuth credentials in the Google Cloud Console.
-
Add your Valet server's callback URL to Authorized redirect URIs:
https://your-project.fly.valet.host/auth/oauth/google/callback -
Set the credentials:
# terminal bunx valet-dev env set GOOGLE_CLIENT_ID=your_client_id bunx valet-dev env set GOOGLE_CLIENT_SECRET=your_client_secret -
Add a sign-in button:
<button onClick={() => signInWithOAuth('google')}> Continue with Google </button>
OAuth-only example
If your app only uses OAuth (no email/password), the login form is just a button or two:
import { useAuth } from 'valet-dev/react'
function LoginForm() {
const { signInWithOAuth } = useAuth()
return (
<div>
<h2>Sign in to continue</h2>
<button onClick={() => signInWithOAuth('github')}>
Continue with GitHub
</button>
<button onClick={() => signInWithOAuth('google')}>
Continue with Google
</button>
</div>
)
}Email and password
Use email/password if you want users to create accounts with a traditional sign-up form. No external service or API keys needed.
There is no additional server setup beyond the common setup — email/password auth works as soon as authTables and VALET_AUTH_SECRET are configured.
Sign-up and sign-in
Use signUp to create an account and signIn to authenticate:
import { useState } from 'react'
import { useAuth } from 'valet-dev/react'
function LoginForm() {
const { signUp, signIn } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault()
try {
await signIn({ email, password })
} catch (err: any) {
setError(err.message)
}
}
const handleSignUp = async () => {
try {
await signUp({ email, password, name: 'Alice' })
} catch (err: any) {
setError(err.message)
}
}
return (
<form onSubmit={handleSignIn}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <p>{error}</p>}
<button type="submit">Sign In</button>
<button type="button" onClick={handleSignUp}>Sign Up</button>
</form>
)
}Passwords must be at least 8 characters. They are hashed with Argon2id on the server.
Combining methods
You can offer both OAuth and email/password on the same login form. They share the same user table — if a user signs up with email/password and later signs in with an OAuth provider that has the same email, Valet automatically links the accounts. The user can then sign in with either method.
import { useState } from 'react'
import { useAuth } from 'valet-dev/react'
function LoginForm() {
const { signIn, signUp, signInWithOAuth } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
return (
<div>
<button onClick={() => signInWithOAuth('github')}>
Continue with GitHub
</button>
<hr />
<form onSubmit={async (e) => {
e.preventDefault()
await signIn({ email, password })
}}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Sign In</button>
<button type="button" onClick={() => signUp({ email, password })}>
Sign Up
</button>
</form>
</div>
)
}External auth providers
If you already use Clerk, Firebase, Auth0, or another auth service, you can validate their tokens directly. The Valet server fetches the provider's public keys via JWKS and verifies RS256/ES256 signatures natively — no proxy backend needed.
# terminal — scaffolds auth.ts with a customJwt template
bunx valet-dev auth init --externalSetup
-
Configure
customJwt()in yourvalet/auth.tswith your provider's issuer and JWKS URL:// valet/auth.ts import { defineAuth, customJwt } from 'valet-dev/server' export default defineAuth({ providers: [ customJwt({ issuer: 'https://your-provider.example.com', jwksUrl: 'https://your-provider.example.com/.well-known/jwks.json', }) ] })The
issuermust match theissclaim in your provider's tokens. ThejwksUrlis where the server fetches public keys for signature verification. These are standard OIDC values — every provider publishes them. -
Pass the provider's token to
ValetProvider:import { ValetProvider } from './_generated/valet/react' function App() { return ( <ValetProvider url={process.env.VALET_PROJECT_URL!} authToken={async () => { // Get the token from your auth provider return await getTokenFromYourProvider() }} > <TodoApp /> </ValetProvider> ) } -
Deploy. The server fetches JWKS keys on deploy and validates tokens against them.
# terminal bunx valet-dev deploy
customJwt options
customJwt({
issuer: 'https://...', // Required — expected iss claim
jwksUrl: 'https://.../.well-known/jwks.json', // Required — JWKS endpoint
audience: 'my-app-id', // Optional — expected aud claim
algorithm: 'RS256', // Optional — RS256 (default) or ES256
})Clerk
// valet/auth.ts
import { defineAuth, customJwt } from 'valet-dev/server'
export default defineAuth({
providers: [
customJwt({
issuer: 'https://your-app.clerk.accounts.dev',
jwksUrl: 'https://your-app.clerk.accounts.dev/.well-known/jwks.json',
})
]
})// App.tsx
import { ValetProvider } from './_generated/valet/react'
import { useAuth } from '@clerk/clerk-react'
function ValetWrapper({ children }: { children: React.ReactNode }) {
const { getToken } = useAuth()
return (
<ValetProvider
url={process.env.VALET_PROJECT_URL!}
authToken={() => getToken()}
>
{children}
</ValetProvider>
)
}Firebase Auth
// valet/auth.ts
import { defineAuth, customJwt } from 'valet-dev/server'
export default defineAuth({
providers: [
customJwt({
issuer: 'https://securetoken.google.com/YOUR_PROJECT_ID',
jwksUrl: 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com',
audience: 'YOUR_PROJECT_ID',
})
]
})// App.tsx
import { ValetProvider } from './_generated/valet/react'
import { getAuth } from 'firebase/auth'
function App() {
return (
<ValetProvider
url={process.env.VALET_PROJECT_URL!}
authToken={async () => {
const user = getAuth().currentUser
if (!user) return null
return await user.getIdToken()
}}
authRefreshBuffer={300_000}
>
<TodoApp />
</ValetProvider>
)
}Auth0
// valet/auth.ts
import { defineAuth, customJwt } from 'valet-dev/server'
export default defineAuth({
providers: [
customJwt({
issuer: 'https://your-tenant.auth0.com/',
jwksUrl: 'https://your-tenant.auth0.com/.well-known/jwks.json',
audience: 'your-api-identifier',
})
]
})Mixing built-in and external auth
You can use customJwt() alongside built-in OAuth providers. The server tries JWKS validation first (matching by issuer), then falls back to HMAC for built-in auth tokens:
// valet/auth.ts
import { defineAuth, github, customJwt } from 'valet-dev/server'
export default defineAuth({
providers: [
github(),
customJwt({
issuer: 'https://your-app.clerk.accounts.dev',
jwksUrl: 'https://your-app.clerk.accounts.dev/.well-known/jwks.json',
})
]
})Token refresh
Valet can automatically refresh tokens before they expire. Set authRefreshBuffer to control how far in advance (in milliseconds) the authToken function is called again:
<ValetProvider
url={process.env.VALET_PROJECT_URL!}
authToken={async () => {
return await getTokenFromYourProvider()
}}
authRefreshBuffer={60_000}
>With authRefreshBuffer={60_000}, Valet calls authToken 60 seconds before the current token expires.
Accessing auth in handlers
The handler context object (ctx) includes ctx.auth with the authenticated user's information. This works the same way regardless of which auth approach you use. For a deeper dive into userId types, when ctx.auth is available, and guarding mutations, see User-Scoped Data.
// valet/todos.ts
import { defineMutation, v } from './_generated/valet/api'
export const create = defineMutation({
args: { title: v.string() },
handler: async (ctx, args) => {
if (!ctx.auth) {
throw new Error('Not authenticated')
}
return ctx.db.insert('todos', {
title: args.title,
completed: false,
userId: ctx.auth.userId,
})
},
})| Property | Type | Description |
|---|---|---|
ctx.auth.userId | string | The user's ID from the JWT sub claim |
ctx.auth.claims | Record<string, any> | All custom claims from the JWT |
If the user is not authenticated, ctx.auth is undefined.
Sync filters
Use ctx.auth in sync filters to control which rows sync to each user:
// valet/schema.ts
todos: defineTable({
title: v.string(),
userId: v.string(),
}).sync({
filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
mode: 'full',
}),Each user's device only receives documents that match their filter.
Reference
useAuth hook
The useAuth() hook is available inside ValetAuthProvider. It provides the current auth state and methods for sign-in and sign-out:
import { useAuth } from 'valet-dev/react'
function Profile() {
const { user, isAuthenticated, isLoading, signOut } = useAuth()
if (isLoading) return <div>Loading...</div>
if (!isAuthenticated) return <div>Not signed in</div>
return (
<div>
<p>{user?.email}</p>
<button onClick={signOut}>Sign Out</button>
</div>
)
}| Property | Type | Description |
|---|---|---|
user | AuthUser | null | The current user (id, email, name, emailVerified) |
state | 'pending' | 'authenticated' | 'unauthenticated' | Current auth state |
isAuthenticated | boolean | Whether the user is signed in |
isLoading | boolean | Whether auth state is being restored from storage |
signUp | (params: { email, password, name? }) => Promise<void> | Create an account with email and password |
signIn | (params: { email, password }) => Promise<void> | Sign in with email and password |
signInWithOAuth | (provider: string, redirectUri?) => void | Start an OAuth flow (redirects to provider) |
signOut | () => Promise<void> | Sign out and clear the session |
authClient | ValetAuthClient | The underlying client instance |
useValetAuth hook
The useValetAuth() hook (distinct from useAuth()) provides the WebSocket connection's auth state. Use it inside ValetProvider to check if the sync connection is authenticated:
import { useValetAuth } from './_generated/valet/react'
function SyncAuthStatus() {
const { authState, isAuthenticated, userId, setToken, clearAuth } = useValetAuth()
if (!isAuthenticated) {
return <div>Not connected</div>
}
return <div>Syncing as {userId}</div>
}| Property | Type | Description |
|---|---|---|
authState | AuthState | The full auth state object |
isAuthenticated | boolean | Whether the WebSocket connection is authenticated |
isPending | boolean | Whether the auth token is being validated |
isError | boolean | Whether authentication failed |
userId | string | undefined | The authenticated user's ID |
error | SyncError | undefined | The authentication error, if any |
setToken | (token: TokenProvider) => void | Set a new auth token and re-authenticate |
clearAuth | () => void | Clear the auth token and disconnect |
JWT format
Valet expects a standard JWT with these claims:
| Claim | Required | Description |
|---|---|---|
sub | Yes | User ID, available as ctx.auth.userId |
exp | Yes | Expiration timestamp (Unix seconds) |
iat | No | Issued-at timestamp |
sid | No | Session ID (set automatically by built-in auth) |
Additional claims are accessible through ctx.auth.claims. Include any custom data your app needs (team IDs, roles, permissions).
Environment variables
| Variable | Required | Description |
|---|---|---|
VALET_AUTH_SECRET | Yes (built-in auth) | HMAC secret for signing and verifying JWTs. Not needed when using only customJwt(). |
VALET_SITE_URL | For OAuth | Your client app's URL. The server redirects here after OAuth completes (e.g., http://localhost:8081 for dev, https://myapp.com for prod). |
VALET_DEV | No | Set to 1 or true to enable development mode (accepts any token). Equivalent to --dev flag. |
GITHUB_CLIENT_ID | For GitHub OAuth | GitHub OAuth app client ID |
GITHUB_CLIENT_SECRET | For GitHub OAuth | GitHub OAuth app client secret |
GOOGLE_CLIENT_ID | For Google OAuth | Google OAuth client ID |
GOOGLE_CLIENT_SECRET | For Google OAuth | Google OAuth client secret |
Set these with the valet-dev env command:
# terminal
bunx valet-dev env set VALET_AUTH_SECRET=$(openssl rand -base64 32)
bunx valet-dev env set GITHUB_CLIENT_ID=your_id
bunx valet-dev env set GITHUB_CLIENT_SECRET=your_secret