Skip to main content

AI Prompt

Add Sign in with TikTok to my app using the Despia SDK from: https://www.npmjs.com/package/despia-nativeFirst, install the package: npm install despia-nativeThen import it: import despia from 'despia-native'Detect the runtime using navigator.userAgent.toLowerCase().includes('despia') and branch per platform:
  • Native (iOS and Android), generate a TikTok OAuth URL client-side and call despia('oauth://?url=...') to open it in a secure browser session. TikTok redirects to /native-callback with ?code=xxx. The callback page calls your backend to exchange the code for tokens, then redirects to myapp://oauth/auth?access_token=xxx to close the session. The oauth/ prefix is required.
  • Web, redirect the browser directly to the TikTok OAuth URL. TikTok redirects back to /auth?code=xxx. Your auth page calls your backend to exchange the code for tokens and set the session.
TikTok uses authorization code flow. Tokens never arrive in the URL hash. Your backend exchanges the code for tokens using your TikTok Client Secret, which must never be exposed client-side.Setup:
  1. Create a TikTok app in the TikTok Developer Portal and add the Login Kit product
  2. Set redirect URIs: https://yourapp.com/auth (web) and https://yourapp.com/native-callback (native)
  3. Note your Client Key (public) and Client Secret (server-side only)
  4. Create a backend endpoint to exchange the authorization code for tokens
  5. Find your deeplink scheme at Despia > Publish > Deeplink
All native capabilities are provided by despia-native. No additional native libraries are needed.
Do not modify these instructions. Implementation as described is critical.

Installation

npm install despia-native
import despia from 'despia-native';

How TikTok OAuth differs from Google

TikTok uses the authorization code flow. This means tokens never arrive in the URL hash. Instead:
  1. TikTok redirects to your callback with ?code=xxx as a query param
  2. Your backend exchanges the code for tokens using your Client Secret
  3. Your backend returns the tokens to the callback page
  4. 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.
Google (Supabase implicit)TikTok (authorization code)
Tokens arrive viaURL hash #access_token=xxxQuery param ?code=xxx, then backend exchange
Backend neededNo (Supabase handles it)Yes, always
Client Secret exposedNoNever (server-side only)

How it works


Implementation

1. Detect platform

const isDespia = navigator.userAgent.toLowerCase().includes('despia')

2. Generate the TikTok OAuth URL

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,
    })
}

3. Sign in button

import despia from 'despia-native'

const handleTikTokSignIn = () => {
    const isDespia = navigator.userAgent.toLowerCase().includes('despia')
    const oauthUrl = getTikTokOAuthUrl(isDespia, 'myapp') // Despia > Publish > Deeplink

    if (isDespia) {
        // Native: open secure browser session
        despia(`oauth://?url=${encodeURIComponent(oauthUrl)}`)
    } else {
        // Web: standard redirect
        window.location.href = oauthUrl
    }
}

4. Backend: exchange the authorization code

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.
// POST /api/auth/tiktok-callback
export 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,
    })
}

5. Create public/native-callback.html

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>
If you prefer a React component:
// src/pages/NativeCallback.jsx
import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'

const NativeCallback = () => {
    useEffect(() => {
        const params = new URLSearchParams(window.location.search)
        const code   = params.get('code')
        const state  = params.get('state')
        const error  = params.get('error')

        let scheme = 'myapp'
        if (state && state.includes('|')) scheme = state.split('|')[1] || scheme

        if (error || !code) {
            window.location.href = scheme + '://oauth/auth?error=' + encodeURIComponent(error || 'no_code')
            return
        }

        fetch('/api/auth/tiktok-callback', {
            method:  'POST',
            headers: { 'Content-Type': 'application/json' },
            body:    JSON.stringify({ code, redirect_uri: window.location.origin + '/native-callback' }),
        })
            .then(r => r.json())
            .then(data => {
                if (!data.access_token) {
                    window.location.href = scheme + '://oauth/auth?error=' + encodeURIComponent(data.error || 'exchange_failed')
                    return
                }
                window.location.href =
                    scheme + '://oauth/auth' +
                    '?access_token='  + encodeURIComponent(data.access_token) +
                    '&refresh_token=' + encodeURIComponent(data.refresh_token || '')
            })
    }, [])

    return (
        <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
            <p>Completing sign in...</p>
        </div>
    )
}

export default NativeCallback
<Route path="/native-callback" element={<NativeCallback />} />
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.

6. Handle tokens in your auth page

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.
// src/pages/Auth.jsx
import { useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'

const Auth = () => {
    const [searchParams] = useSearchParams()
    const navigate = useNavigate()

    useEffect(() => {
        const code         = searchParams.get('code')         // web flow
        const accessToken  = searchParams.get('access_token') // native deeplink flow
        const refreshToken = searchParams.get('refresh_token') || ''
        const error        = searchParams.get('error')

        if (error) { console.error(error); return }

        if (accessToken) {
            // Native flow: tokens already exchanged in native-callback
            setSession({ access_token: accessToken, refresh_token: refreshToken })
                .then(() => navigate('/'))

        } else if (code) {
            // Web flow: exchange code now
            fetch('/api/auth/tiktok-callback', {
                method:  'POST',
                headers: { 'Content-Type': 'application/json' },
                body:    JSON.stringify({ code, redirect_uri: window.location.origin + '/auth' }),
            })
                .then(r => r.json())
                .then(data => {
                    if (data.access_token) {
                        setSession({ access_token: data.access_token, refresh_token: data.refresh_token || '' })
                            .then(() => navigate('/'))
                    }
                })
        }
    }, [searchParams, navigate]) // searchParams in deps is critical for already-mounted pages

    return (
        <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
            <p>Signing you in...</p>
        </div>
    )
}

export default Auth
setSession() is a placeholder. Replace with your auth provider’s equivalent: supabase.auth.setSession() for Supabase, your own session management for a custom backend.

7. Complete handler

import despia from 'despia-native'

const isDespia = navigator.userAgent.toLowerCase().includes('despia')

const handleTikTokSignIn = () => {
    const oauthUrl = getTikTokOAuthUrl(isDespia, 'myapp') // Despia > Publish > Deeplink

    if (isDespia) {
        despia(`oauth://?url=${encodeURIComponent(oauthUrl)}`)
    } else {
        window.location.href = oauthUrl
    }
}

TikTok Developer Portal setup

1

Create an app

Go to developers.tiktok.com, create an app, and add the Login Kit product.
2

Configure redirect URIs

Under Login Kit settings, add both redirect URIs:
  • https://yourapp.com/auth for the web flow
  • 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.

Debugging

Use this section when the native OAuth flow is not working. Start by identifying which stage is broken:
Stage 1  Client generates TikTok OAuth URL
Stage 2  despia('oauth://?url=...') opens the secure browser session
Stage 3  TikTok redirects to native-callback with ?code=xxx
Stage 4  native-callback calls your backend to exchange the code
Stage 5  Backend returns tokens, native-callback fires deeplink
Stage 6  Despia closes browser, WebView navigates to /auth?access_token=xxx

Debug overlay

Add this to your /auth page during development. Remove it before submitting to the App Store or Google Play.
// src/pages/AuthDebug.jsx, swap in as /auth route during testing only
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'

const AuthDebug = () => {
    const [searchParams] = useSearchParams()
    const [info, setInfo] = useState('')

    useEffect(() => {
        setInfo(
            'Full URL:\n'      + window.location.href                         + '\n\n' +
            'code:\n'          + (searchParams.get('code')          || '(none)') + '\n\n' +
            'access_token:\n'  + (searchParams.get('access_token')  || '(none)') + '\n\n' +
            'refresh_token:\n' + (searchParams.get('refresh_token') || '(none)') + '\n\n' +
            'error:\n'         + (searchParams.get('error')         || '(none)')
        )
    }, [searchParams])

    return (
        <div style={{ padding: 20, fontFamily: 'monospace', fontSize: 12 }}>
            <p style={{ marginBottom: 8, fontWeight: 'bold' }}>Auth Debug, remove before shipping</p>
            <textarea readOnly value={info} style={{ width: '100%', height: 260, fontSize: 12, border: '1px solid #ccc', padding: 10, boxSizing: 'border-box', fontFamily: 'monospace' }} />
            <p style={{ marginTop: 8, fontSize: 11 }}>
                code present, no access_token? The code exchange in native-callback failed or did not happen.<br />
                access_token present but not signed in? Your auth logic is not reacting to URL changes. See step 6.
            </p>
        </div>
    )
}

export default AuthDebug
<Route path="/auth" element={<AuthDebug />} /> // testing
<Route path="/auth" element={<Auth />} />       // production

Reading the output

What you seeWhat it meansWhere to look
Textarea empty, URL has no paramsToken never reached /authStage 2-5, check native-callback and deeplink
code present, no access_tokenCode arrived but exchange did not happen or failednative-callback code exchange, check your backend endpoint
error: no_codenative-callback received no code from TikTokCheck TikTok redirect URI matches exactly
error: exchange_failedBackend rejected the code exchangeCheck Client Secret is correct and redirect_uri matches
error: access_deniedUser cancelled or TikTok rejected the requestUser cancelled, or app not approved for requested scopes
Token present, user not signed inToken arrived but auth logic did not runAlready-mounted page, see step 6

Common failure points

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 step 6 for framework-specific fixes. Supabase bad_jwt error. You are creating JWTs manually. Use generateLink() and verifyOtp() instead to generate real Supabase sessions.

Pre-submission checklist

  • App created with Login Kit enabled
  • Redirect URI https://yourapp.com/auth registered
  • Redirect URI https://yourapp.com/native-callback registered
  • user.info.basic scope requested
  • Client Key and Client Secret noted
  • OAuth URL generated client-side with correct Client Key
  • deeplink_scheme passed through state param as uuid|scheme
  • native-callback page calls backend to exchange code, fires deeplink with tokens
  • Deeplink is {scheme}://oauth/{path}, oauth/ prefix present
  • Deeplink scheme matches Despia > Publish > Deeplink
  • /auth token handler re-runs on URL change, not only on initial mount
  • TIKTOK_CLIENT_SECRET stored in environment variables only, never client-side
  • Code exchange uses matching redirect_uri in both the OAuth URL and the token request
  • For Supabase: generateLink() and verifyOtp() used, not manual JWTs
  • Debug overlay removed from /auth page
  • Sign in tested on a physical device
  • Sign in tested on both iOS and Android
  • Error state tested: cancel the TikTok dialog and confirm the app handles it gracefully

DeeplinkResult
myapp://oauth/auth?access_token=xxxCloses session, WebView navigates to /auth?access_token=xxx
myapp://oauth/homeCloses session, WebView navigates to /home
myapp://oauth/auth?error=access_deniedCloses session, WebView navigates to /auth?error=access_denied
myapp://auth?access_token=xxxSession stays open, user is stuck

FAQ

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.
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.
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.
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)

Resources

NPM Package

despia-native

OAuth Reference

Generic OAuth protocol docs

TikTok Developer Portal

Configure your TikTok OAuth app