Sign In with TikTok using the Despia OAuth bridge. Opens ASWebAuthenticationSession on iOS and Chrome Custom Tabs on Android, with a standard redirect on web.
Sign In with TikTok using authorization code flow through the Despia OAuth bridge. Native opens a secure browser session via oauth://, web uses a standard redirect, and the code-to-token exchange always runs server-side because TikTok’s Client Secret cannot be exposed client-side.
All native capabilities here come from despia-native. No additional native libraries are needed.
TikTok uses the authorization code flow. This means tokens never arrive in the URL hash. Instead:
TikTok redirects to your callback with ?code=xxx as a query param
Your backend exchanges the code for tokens using your Client Secret
Your backend returns the tokens to the callback page
The callback page fires the deeplink with the tokens
This is more secure than the implicit flow because your Client Secret never leaves your server. It also means you need a backend endpoint, so there is no hash-based shortcut.
TikTok’s Client Key is public, it is visible in the OAuth URL the user sees. You can generate the OAuth URL client-side. The Client Secret is never needed here.Pass deeplink_scheme through the state parameter so your callback knows how to build the deeplink. TikTok echoes state back unchanged after auth.
Find your deeplink scheme at Despia > Publish > Deeplink. Replace myapp throughout with your actual scheme.
function getTikTokOAuthUrl(isNative, deeplinkScheme) { const TIKTOK_CLIENT_KEY = 'your_tiktok_client_key' // public, safe client-side const APP_URL = 'https://yourapp.com' const state = crypto.randomUUID() const redirectUri = isNative ? `${APP_URL}/native-callback` : `${APP_URL}/auth` // Encode deeplink_scheme into state so the callback can read it const stateParam = isNative ? `${state}|${deeplinkScheme}` : state return 'https://www.tiktok.com/v2/auth/authorize/?' + new URLSearchParams({ client_key: TIKTOK_CLIENT_KEY, response_type: 'code', scope: 'user.info.basic', redirect_uri: redirectUri, state: stateParam, })}
Your backend receives the code from the callback page, exchanges it with TikTok for tokens, optionally fetches the user profile, then returns the tokens to the client. The Client Secret is only ever used server-side.
Custom Backend
No-Code Platform (Supabase)
// POST /api/auth/tiktok-callbackexport async function POST(req) { const { code, redirect_uri } = await req.json() // Exchange code for TikTok access token const tokenRes = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_key: process.env.TIKTOK_CLIENT_KEY, client_secret: process.env.TIKTOK_CLIENT_SECRET, code, grant_type: 'authorization_code', redirect_uri, }), }) const tokenData = await tokenRes.json() if (!tokenData.access_token) { return Response.json({ error: 'Token exchange failed' }, { status: 400 }) } // Optionally fetch TikTok user profile const userRes = await fetch( 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,avatar_url', { headers: { Authorization: `Bearer ${tokenData.access_token}` } } ) const userData = await userRes.json() // Create or sign in the user in your own system, return your own session tokens const session = await createOrSignInUser({ tiktokOpenId: tokenData.open_id, displayName: userData.data?.user?.display_name, avatarUrl: userData.data?.user?.avatar_url, tiktokToken: tokenData.access_token, }) return Response.json({ access_token: session.access_token, refresh_token: session.refresh_token, })}
TikTok is not a native Supabase auth provider. Use a Supabase Edge Function to exchange the code, create or find the Supabase user, and generate a real session using generateLink() and verifyOtp(). Do not attempt to create JWTs manually, they will fail with bad_jwt errors.
// supabase/functions/auth-tiktok-callback/index.tsimport { serve } from 'https://deno.land/std@0.168.0/http/server.ts'import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'serve(async (req) => { if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders }) const { code, redirect_uri } = await req.json() const clientKey = Deno.env.get('TIKTOK_CLIENT_KEY') const clientSecret = Deno.env.get('TIKTOK_CLIENT_SECRET') const supabaseUrl = Deno.env.get('SUPABASE_URL') // Exchange code for TikTok tokens const tokenRes = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_key: clientKey, client_secret: clientSecret, code, grant_type: 'authorization_code', redirect_uri, }), }) const { access_token, open_id } = await tokenRes.json() // Fetch TikTok user info const userRes = await fetch( 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,avatar_url', { headers: { Authorization: `Bearer ${access_token}` } } ) const tiktokUser = (await userRes.json()).data?.user // Use admin client to create/find the user const admin = createClient(supabaseUrl, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')) const public_ = createClient(supabaseUrl, Deno.env.get('SUPABASE_ANON_KEY')) const email = `${open_id.toLowerCase()}@tiktok.oauth` let { error: createErr } = await admin.auth.admin.createUser({ email, email_confirm: true, user_metadata: { tiktok_open_id: open_id, display_name: tiktokUser?.display_name, avatar_url: tiktokUser?.avatar_url }, }) // If user already exists that is fine, continue // Generate a real Supabase session via magic link + verifyOtp // Do NOT create JWTs manually, this is the correct approach const { data: link } = await admin.auth.admin.generateLink({ type: 'magiclink', email }) const { data: session } = await public_.auth.verifyOtp({ token_hash: link.properties.hashed_token, type: 'email', // use 'magiclink' for Supabase JS < 2.39 }) return new Response( JSON.stringify({ access_token: session.session.access_token, refresh_token: session.session.refresh_token }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } )})
SUPABASE_URL, SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY are provided automatically. Use SUPABASE_ANON_KEY not SUPABASE_PUBLISHABLE_KEY or you will get a “supabaseKey is required” error.
This page runs inside the secure browser session. TikTok redirects here with ?code=xxx. Unlike Google’s implicit flow, the code arrives as a query param, not a hash fragment, so React Router stripping the hash is not a concern here. A plain HTML file is still recommended to keep it simple and ensure it always runs fresh.
The code exchange happens inside this page via a fetch call to your backend. The page stays open while the exchange happens, then fires the deeplink to close the session.
<!-- public/native-callback.html --><!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> <style> body { margin: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #888; font-size: 14px; } </style></head><body> <p>Completing sign in...</p> <script> (function () { var params = new URLSearchParams(window.location.search) var code = params.get('code') var state = params.get('state') var error = params.get('error') // Extract deeplink_scheme from state (format: "uuid|scheme" or just "uuid") var scheme = 'myapp' // fallback - replace with your scheme if (state && state.includes('|')) { scheme = state.split('|')[1] || scheme } if (error || !code) { window.location.href = scheme + '://oauth/auth?error=' + encodeURIComponent(error || 'no_code') return } // Exchange code for tokens via your backend fetch('/api/auth/tiktok-callback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, redirect_uri: window.location.origin + '/native-callback', }), }) .then(function (r) { return r.json() }) .then(function (data) { if (!data.access_token) { window.location.href = scheme + '://oauth/auth?error=' + encodeURIComponent(data.error || 'exchange_failed') return } // The oauth/ prefix tells Despia to close the secure browser session window.location.href = scheme + '://oauth/auth' + '?access_token=' + encodeURIComponent(data.access_token) + '&refresh_token=' + encodeURIComponent(data.refresh_token || '') }) .catch(function (err) { window.location.href = scheme + '://oauth/auth?error=' + encodeURIComponent('network_error') }) })() </script></body></html>
The oauth/ prefix in the deeplink is required. myapp://oauth/auth closes the browser session and navigates the WebView to /auth. myapp://auth without it does nothing and the user stays stuck in the browser.
After Despia closes the session and navigates to /auth?access_token=xxx, your auth page reads the token and sets the session. The web flow also lands here after exchanging the code.
If /auth is already mounted when the deeplink arrives, your framework updates the URL without remounting. Token-reading logic that only runs on mount has already fired with empty params and will not run again. The fix is framework-specific and covered in the tabs below.
function handleAuthParams() { var p = new URLSearchParams(window.location.search) var code = p.get('code') var accessToken = p.get('access_token') var refreshToken = p.get('refresh_token') || '' var error = p.get('error') if (error) { console.error(error); return } if (accessToken) { setSession({ access_token: accessToken, refresh_token: refreshToken }) .then(function () { window.location.href = '/' }) } else if (code) { fetch('/api/auth/tiktok-callback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, redirect_uri: window.location.origin + '/auth' }), }) .then(function (r) { return r.json() }) .then(function (data) { if (data.access_token) { setSession({ access_token: data.access_token, refresh_token: data.refresh_token || '' }) .then(function () { window.location.href = '/' }) } }) }}handleAuthParams()window.addEventListener('popstate', handleAuthParams)
<p id="status">Signing you in...</p><script> function handleAuthParams() { var p = new URLSearchParams(window.location.search) var code = p.get('code') var accessToken = p.get('access_token') var refreshToken = p.get('refresh_token') || '' var error = p.get('error') if (!code && !accessToken && !error) return if (error) { document.getElementById('status').textContent = 'Sign in failed: ' + error; return } if (accessToken) { setSession({ access_token: accessToken, refresh_token: refreshToken }) .then(function () { window.location.href = '/' }) } else if (code) { fetch('/api/auth/tiktok-callback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, redirect_uri: window.location.origin + '/auth' }), }) .then(function (r) { return r.json() }) .then(function (data) { if (data.access_token) { setSession({ access_token: data.access_token, refresh_token: data.refresh_token || '' }) .then(function () { window.location.href = '/' }) } }) } } handleAuthParams() window.addEventListener('popstate', handleAuthParams)</script>
setSession() is a placeholder. Replace with your auth provider’s equivalent: supabase.auth.setSession() for Supabase, your own session management for a custom backend.
https://yourapp.com/native-callback for the native flow
Both must be registered exactly as they appear in your code.
3
Request scopes
Request at minimum user.info.basic. This provides open_id, display_name, and avatar_url. Additional scopes (user.info.profile, user.info.stats) are optional.
4
Note your credentials
Copy your Client Key (public, used client-side) and Client Secret (private, server-side only). Store the Client Secret in your backend environment variables only. Never expose it client-side.
5
Configure your deeplink scheme
Find your scheme at Despia > Publish > Deeplink and replace myapp in your code with the actual value.
Secure browser session does not open. Log the URL before passing to despia() and confirm it starts with https://www.tiktok.com/v2/auth/authorize/.TikTok redirects to wrong URL. Both https://yourapp.com/auth and https://yourapp.com/native-callback must be registered in the TikTok Developer Portal under Login Kit. The redirect URI in your code must match exactly including the protocol and no trailing slash.Code exchange fails. The redirect_uri sent to your backend must exactly match what was used in the original OAuth URL. A mismatch will cause TikTok to reject the exchange even if the code is valid.Deeplink does not close the browser session.oauth/ must be present, myapp://oauth/auth. Without it Despia does not intercept the deeplink. Find your scheme at Despia > Publish > Deeplink.Tokens arrive but sign-in never completes. The auth page was already mounted when the deeplink arrived. See Handling tokens at /auth for framework-specific fixes.Supabase bad_jwt error. You are creating JWTs manually. Use generateLink() and verifyOtp() instead to generate real Supabase sessions.
Why does TikTok need a backend but Google does not (with Supabase)?
TikTok uses authorization code flow, which requires exchanging a short-lived code for tokens using your Client Secret. The Client Secret must never be exposed client-side. Supabase handles Google’s exchange internally, which is why the Google flow appears to not need a backend. For TikTok you always need a backend endpoint to do the exchange.
Why is the code exchanged in native-callback, not in /auth?
The native-callback page runs inside the secure browser session (Chrome Custom Tabs / ASWebAuthenticationSession). Doing the exchange here means the tokens are ready before the deeplink fires. If you waited until /auth, you would need to carry the raw code through the deeplink instead of the tokens, which is possible but adds an extra round trip after the browser closes.
What does the oauth/ prefix do?
It signals Despia to close the secure browser session and navigate the WebView to the path that follows. myapp://oauth/auth closes the session and opens /auth. Without oauth/ the deeplink is ignored and the user stays in the browser.
Why pass deeplink_scheme through state?
Once TikTok opens the secure browser session, your native-callback page has no direct access to the original Despia context. The state parameter is the only value TikTok echoes back unchanged, making it the correct carrier for anything your callback needs to know about the original request.
Tokens are in the URL but the user is not signed in.
The /auth page was already open when the deeplink arrived. Your framework updated the URL without reloading.Fix per framework:
React, add searchParams to your useEffect dependency array
Vue, use watch: { '$route.query': { immediate: true, handler } } instead of mounted()
Vanilla JS / HTML, call your handler on load and add window.addEventListener('popstate', handler)