Native Authentication

Build custom login UI with Transactional Auth as a headless backend.

Overview

Native authentication lets you build your own login, signup, and account management UI while using Transactional Auth as the backend. Users never see Transactional's hosted pages - they interact entirely with your branded interface.

When to Use Native Auth

Use CaseRecommended Approach
Custom branded login UINative Auth
Mobile apps (React Native, Swift, Kotlin)Native Auth
Desktop apps (Electron, Tauri)Native Auth
CLI toolsNative Auth
Standard web appsOAuth or Better Auth
Enterprise SSO requiredSSO

How It Works

┌─────────────────────────────────────┐
│         Your Application            │
│                                     │
│  ┌─────────────┐  ┌──────────────┐  │
│  │ Your Login  │  │  Your API    │  │
│  │    Form     │──┼──────────────┤  │
│  └─────────────┘  │              │  │
│                   │  Validates   │  │
│                   │  JWT tokens  │  │
│                   └──────────────┘  │
└────────────────────┬────────────────┘
                     │
                     │ HTTPS
                     ▼
┌─────────────────────────────────────┐
│  Transactional Auth (Headless API)  │
│  your-app.auth.usetransactional.com │
│                                     │
│  /api/auth/signup                   │
│  /api/auth/login                    │
│  /api/auth/refresh                  │
│  /api/auth/logout                   │
│  /api/auth/me                       │
└─────────────────────────────────────┘

Prerequisites

  • A Transactional account with an Auth application
  • Your application's Auth Domain (e.g., your-app-abc123.auth.usetransactional.com)
  • Your Client ID and Client Secret

API Endpoints

All endpoints are available at your application's Auth Domain:

https://your-app-abc123.auth.usetransactional.com/api/auth/
EndpointMethodDescription
/api/auth/signupPOSTCreate new user account
/api/auth/loginPOSTAuthenticate and get tokens
/api/auth/refreshPOSTRefresh access token
/api/auth/logoutPOSTRevoke refresh token
/api/auth/meGETGet current user profile
/api/auth/forgot-passwordPOSTRequest password reset

Authentication

All requests must include your client credentials:

// Option 1: In request body
{
  "client_id": "your_client_id",
  "client_secret": "your_client_secret",
  "email": "user@example.com",
  "password": "..."
}
 
// Option 2: In headers
headers: {
  "x-client-id": "your_client_id",
  "x-client-secret": "your_client_secret",
  "Content-Type": "application/json"
}

Signup

Create a new user account:

const response = await fetch(
  'https://your-app.auth.usetransactional.com/api/auth/signup',
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: 'your_client_id',
      client_secret: 'your_client_secret',
      email: 'user@example.com',
      password: 'securepassword123',
      name: 'John Doe',
      given_name: 'John',
      family_name: 'Doe',
    }),
  }
);
 
const { data } = await response.json();
// {
//   user: { id, email, name, email_verified, ... },
//   access_token: "eyJ...",
//   refresh_token: "...",
//   expires_in: 3600,
//   token_type: "Bearer"
// }

Signup Errors

Error CodeDescription
invalid_clientInvalid client_id or client_secret
invalid_requestMissing email or password
invalid_emailEmail format invalid
weak_passwordPassword must be at least 8 characters
user_existsAccount with this email already exists

Login

Authenticate an existing user:

const response = await fetch(
  'https://your-app.auth.usetransactional.com/api/auth/login',
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: 'your_client_id',
      client_secret: 'your_client_secret',
      email: 'user@example.com',
      password: 'securepassword123',
    }),
  }
);
 
const { data } = await response.json();
// {
//   user: { id, email, name, ... },
//   access_token: "eyJ...",
//   refresh_token: "...",
//   expires_in: 3600,
//   token_type: "Bearer"
// }

Login Errors

Error CodeDescription
invalid_credentialsWrong email or password
user_blockedAccount has been blocked
user_deactivatedAccount has been deactivated

Token Refresh

Access tokens expire after 1 hour. Use refresh tokens to get new access tokens:

const response = await fetch(
  'https://your-app.auth.usetransactional.com/api/auth/refresh',
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: 'your_client_id',
      refresh_token: storedRefreshToken,
    }),
  }
);
 
const { data } = await response.json();
// {
//   access_token: "eyJ...",     // New access token
//   refresh_token: "...",        // NEW refresh token (rotation)
//   expires_in: 3600,
//   token_type: "Bearer"
// }
 
// IMPORTANT: Always store the new refresh token!

Token Rotation

Refresh tokens are rotated on each use for security. Always store the new refresh token returned from the refresh endpoint.

If a refresh token is reused (indicating possible theft), all tokens for that user are automatically revoked.

Refresh Errors

Error CodeDescription
invalid_tokenRefresh token not found
token_expiredRefresh token has expired (30 days)
token_reusedToken already used (possible theft)

Get Current User

Retrieve the authenticated user's profile:

const response = await fetch(
  'https://your-app.auth.usetransactional.com/api/auth/me',
  {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
    },
  }
);
 
const { data } = await response.json();
// {
//   id: "usr_...",
//   email: "user@example.com",
//   name: "John Doe",
//   given_name: "John",
//   family_name: "Doe",
//   email_verified: true,
//   picture: "https://...",
//   created_at: "2024-01-15T...",
//   updated_at: "2024-01-20T..."
// }

Logout

Revoke a refresh token to end the session:

await fetch(
  'https://your-app.auth.usetransactional.com/api/auth/logout',
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      refresh_token: storedRefreshToken,
    }),
  }
);

Password Reset

Request a password reset email:

await fetch(
  'https://your-app.auth.usetransactional.com/api/auth/forgot-password',
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: 'your_client_id',
      email: 'user@example.com',
    }),
  }
);
 
// Always returns success to prevent email enumeration

Access Token Structure

Access tokens are JWTs with these claims:

{
  "sub": "usr_abc123",
  "email": "user@example.com",
  "email_verified": true,
  "name": "John Doe",
  "org_id": 123,
  "app_id": 456,
  "iss": "https://your-app.auth.usetransactional.com",
  "aud": "your_client_id",
  "iat": 1704067200,
  "exp": 1704070800
}

Token Validation

Validate tokens in your backend:

import jwt from 'jsonwebtoken';
 
function validateToken(token: string) {
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      audience: process.env.TRANSACTIONAL_CLIENT_ID,
      issuer: `https://${process.env.TRANSACTIONAL_AUTH_SUBDOMAIN}.auth.usetransactional.com`,
    });
    return { valid: true, user: decoded };
  } catch (error) {
    return { valid: false, error: error.message };
  }
}

React Example

Complete React hook for native auth:

// hooks/useAuth.ts
import { useState, useEffect, createContext, useContext } from 'react';
 
const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL;
const CLIENT_ID = process.env.NEXT_PUBLIC_CLIENT_ID;
const CLIENT_SECRET = process.env.NEXT_PUBLIC_CLIENT_SECRET;
 
interface User {
  id: string;
  email: string;
  name: string;
  email_verified: boolean;
}
 
interface AuthContext {
  user: User | null;
  loading: boolean;
  login: (email: string, password: string) => Promise<void>;
  signup: (email: string, password: string, name: string) => Promise<void>;
  logout: () => Promise<void>;
}
 
const AuthContext = createContext<AuthContext | null>(null);
 
export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
 
  // Check for existing session on mount
  useEffect(() => {
    const accessToken = localStorage.getItem('access_token');
    if (accessToken) {
      fetchUser(accessToken);
    } else {
      setLoading(false);
    }
  }, []);
 
  async function fetchUser(token: string) {
    try {
      const res = await fetch(`${AUTH_URL}/api/auth/me`, {
        headers: { Authorization: `Bearer ${token}` },
      });
      if (res.ok) {
        const { data } = await res.json();
        setUser(data);
      } else {
        // Token invalid, try refresh
        await refreshTokens();
      }
    } catch {
      localStorage.removeItem('access_token');
      localStorage.removeItem('refresh_token');
    } finally {
      setLoading(false);
    }
  }
 
  async function refreshTokens() {
    const refreshToken = localStorage.getItem('refresh_token');
    if (!refreshToken) return;
 
    const res = await fetch(`${AUTH_URL}/api/auth/refresh`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        client_id: CLIENT_ID,
        refresh_token: refreshToken,
      }),
    });
 
    if (res.ok) {
      const { data } = await res.json();
      localStorage.setItem('access_token', data.access_token);
      localStorage.setItem('refresh_token', data.refresh_token);
      await fetchUser(data.access_token);
    } else {
      localStorage.removeItem('access_token');
      localStorage.removeItem('refresh_token');
      setUser(null);
    }
  }
 
  async function login(email: string, password: string) {
    const res = await fetch(`${AUTH_URL}/api/auth/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        email,
        password,
      }),
    });
 
    if (!res.ok) {
      const { error } = await res.json();
      throw new Error(error.message);
    }
 
    const { data } = await res.json();
    localStorage.setItem('access_token', data.access_token);
    localStorage.setItem('refresh_token', data.refresh_token);
    setUser(data.user);
  }
 
  async function signup(email: string, password: string, name: string) {
    const res = await fetch(`${AUTH_URL}/api/auth/signup`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        email,
        password,
        name,
      }),
    });
 
    if (!res.ok) {
      const { error } = await res.json();
      throw new Error(error.message);
    }
 
    const { data } = await res.json();
    localStorage.setItem('access_token', data.access_token);
    localStorage.setItem('refresh_token', data.refresh_token);
    setUser(data.user);
  }
 
  async function logout() {
    const refreshToken = localStorage.getItem('refresh_token');
    if (refreshToken) {
      await fetch(`${AUTH_URL}/api/auth/logout`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ refresh_token: refreshToken }),
      });
    }
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
    setUser(null);
  }
 
  return (
    <AuthContext.Provider value={{ user, loading, login, signup, logout }}>
      {children}
    </AuthContext.Provider>
  );
}
 
export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error('useAuth must be used within AuthProvider');
  return context;
}

React Native Example

// services/auth.ts
import * as SecureStore from 'expo-secure-store';
 
const AUTH_URL = 'https://your-app.auth.usetransactional.com';
const CLIENT_ID = 'your_client_id';
const CLIENT_SECRET = 'your_client_secret';
 
export async function login(email: string, password: string) {
  const res = await fetch(`${AUTH_URL}/api/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      email,
      password,
    }),
  });
 
  if (!res.ok) {
    const { error } = await res.json();
    throw new Error(error.message);
  }
 
  const { data } = await res.json();
 
  // Store tokens securely
  await SecureStore.setItemAsync('access_token', data.access_token);
  await SecureStore.setItemAsync('refresh_token', data.refresh_token);
 
  return data.user;
}
 
export async function getAccessToken() {
  const token = await SecureStore.getItemAsync('access_token');
  if (!token) return null;
 
  // Check if expired and refresh if needed
  // ... token validation logic
 
  return token;
}

Security Considerations

  1. Store Client Secret Securely

    • For web apps: Store server-side only, never expose to client
    • For mobile/desktop apps: Use secure storage (Keychain, Keystore)
    • For public clients: Consider using PKCE OAuth flow instead
  2. Token Storage

    • Web: Use httpOnly cookies or memory (avoid localStorage in production)
    • Mobile: Use platform secure storage (SecureStore, Keychain)
    • Desktop: Use OS credential manager
  3. HTTPS Only

    • All API calls must use HTTPS in production
    • Never send credentials over unencrypted connections
  4. Rate Limiting

    • Built-in rate limiting on all auth endpoints
    • Implement client-side throttling for better UX

Next Steps

  • Users - User management
  • Sessions - Session management
  • Security - Security policies
  • MFA - Add multi-factor authentication