Skip to content

How To Add OAuth Login

Use this guide when you want users to sign in with an external OAuth provider while still letting Seamless Auth own local users, sessions, cookies, and auth events.

OAuth login is available through:

  • seamless-auth-api raw routes under /oauth/*
  • @seamless-auth/express adapter routes under /auth/oauth/*
  • @seamless-auth/react helpers on useAuth()

React UI
-> POST /auth/oauth/:providerId/start
-> redirect browser to provider
-> provider redirects back to your React callback route
-> POST /auth/oauth/:providerId/callback
-> backend adapter sets Seamless Auth cookies
-> React provider refreshes session state

The OAuth provider secret never belongs in browser code. The auth API reads it from an environment variable named by the provider config.


LOGIN_METHODS must include oauth.

LOGIN_METHODS=passkey,magic_link,email_otp,phone_otp,oauth

Configure providers with OAUTH_PROVIDERS. This value is a JSON array.

OAUTH_PROVIDERS=[{"id":"google","name":"Google","enabled":true,"clientId":"google-client-id.apps.googleusercontent.com","clientSecretEnv":"GOOGLE_CLIENT_SECRET","authorizationUrl":"https://accounts.google.com/o/oauth2/v2/auth","tokenUrl":"https://oauth2.googleapis.com/token","userInfoUrl":"https://openidconnect.googleapis.com/v1/userinfo","scopes":["openid","email","profile"],"redirectUris":["http://localhost:5173/oauth/callback"],"subjectJsonPath":"sub","emailJsonPath":"email","emailVerifiedJsonPath":"email_verified","nameJsonPath":"name","allowSignup":true,"accountLinking":"email","requireEmailVerified":true}]
GOOGLE_CLIENT_SECRET=replace-with-provider-secret
OAUTH_STATE_SECRET=replace-with-long-random-secret

Provider config fields:

FieldPurpose
idRoute identifier, such as google
nameDisplay name returned to the UI
enabledWhether the provider is visible and usable
clientIdOAuth client ID
clientSecretEnvEnv var name that contains the client secret
authorizationUrlProvider authorization endpoint
tokenUrlProvider token endpoint
userInfoUrlProvider userinfo endpoint
scopesProvider scopes sent as a space-delimited OAuth scope value
redirectUriOptional single redirect URI
redirectUrisOptional allowlist of exact redirect URIs
subjectJsonPathProfile field used as provider subject, default sub
emailJsonPathProfile field used as email, default email
emailVerifiedJsonPathProfile field used for email verification, default email_verified
nameJsonPathOptional profile field used as display name metadata
allowSignupWhether OAuth can create a new local user
accountLinkingemail or disabled
requireEmailVerifiedWhether profile email must be verified

In production, set OAUTH_STATE_SECRET. Development can fall back, but production should not rely on derived local state secrets.


The redirect URI passed to startOAuthLogin(...) must be allowed by the provider config.

If redirectUris or redirectUri is set, the requested redirect URI must exactly match one of those values. If no provider redirect allowlist is set, the auth API falls back to the configured Seamless Auth origins.

For local React development, the built-in callback route is:

http://localhost:5173/oauth/callback

That URI must also be registered with the external OAuth provider, and must appear in the provider’s redirectUris allowlist above.


Use listOAuthProviders() so your UI follows the server config.

import { useEffect, useState } from 'react'
import { useAuth, type OAuthProvider } from '@seamless-auth/react'
export function OAuthButtons() {
const { listOAuthProviders, startOAuthLogin } = useAuth()
const [providers, setProviders] = useState<OAuthProvider[]>([])
useEffect(() => {
void listOAuthProviders().then((result) => setProviders(result.providers))
}, [listOAuthProviders])
async function start(provider: OAuthProvider) {
// Remember which provider so the callback can finish the right flow.
sessionStorage.setItem('seamless:oauth:provider', provider.id)
const result = await startOAuthLogin({
providerId: provider.id,
redirectUri: `${window.location.origin}/oauth/callback`,
returnTo: `${window.location.origin}/dashboard`,
})
window.location.assign(result.authorizationUrl)
}
return (
<div>
{providers.map((provider) => (
<button key={provider.id} onClick={() => void start(provider)}>
Continue with {provider.name}
</button>
))}
</div>
)
}

returnTo must be a full URL. The auth API only preserves it when its origin is allowed by the configured origins.


Create a React route that matches the redirectUri you sent in Step 3:

<Route path="/oauth/callback" element={<OAuthCallback />} />

Then finish the login from the callback page. The provider id was stashed in sessionStorage during Step 3, and the code and state come back on the URL.

import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@seamless-auth/react'
export function OAuthCallback() {
const { finishOAuthLogin } = useAuth()
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function finish() {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
const providerId = sessionStorage.getItem('seamless:oauth:provider')
if (!providerId || !code || !state) {
setError('OAuth callback is missing required values.')
return
}
try {
await finishOAuthLogin({ providerId, code, state })
sessionStorage.removeItem('seamless:oauth:provider')
navigate('/dashboard', { replace: true })
} catch {
setError('OAuth login failed.')
}
}
void finish()
}, [finishOAuthLogin, navigate])
if (error) return <p>{error}</p>
return <p>Signing you in...</p>
}

When the Express adapter handles /auth/oauth/:providerId/callback, it verifies the auth API response and sets the access and refresh cookies.


OAuth identity rows link a provider subject to a local user.

The current auth API behavior is:

CaseBehavior
Existing oauth_identities rowReuse the linked local user
No identity, matching user email, accountLinking: "email"Link provider identity to that user
No identity, matching user email, accountLinking: "disabled"Reject login
No matching user, allowSignup: trueCreate a verified local user with default roles
No matching user, allowSignup: falseReject login

The profile email must exist. If requireEmailVerified is true, the provider profile must also report a verified email.


SymptomLikely cause
Provider does not appearLOGIN_METHODS does not include oauth, provider is disabled, or config failed validation
OAuth start returns 400redirectUri is not in the provider allowlist
Provider rejects redirectProvider console does not include the same redirect URI
Callback returns Invalid OAuth stateState expired, wrong provider ID, or OAUTH_STATE_SECRET changed mid-flow
Callback returns OAuth signup is disabledNo matching local user and allowSignup is false
Callback succeeds but React still looks signed outCallback did not hit the backend adapter, cookies were blocked, or refreshSession() did not run

  • Keep provider client secrets in environment variables.
  • Use exact redirect URI allowlists for production providers.
  • Set a stable OAUTH_STATE_SECRET in production.
  • Treat OAuth as authentication only; app authorization still belongs in your backend.
  • Keep APP_ORIGINS, ORIGINS, cookie domain, and provider redirect URIs aligned.