Overview

Aarokya supports two authentication channels:

Direct OTP Login

For standalone app use. Phone OTP -> JWT tokens. No passwords.

Partner SDK Token

For embedded SDK use (NammaYatri). Partner backend requests token via API key. This is the primary flow for drivers.
Both channels issue the same JWT access tokens. Once a token is obtained (via either channel), all subsequent API calls use Authorization: Bearer <token>.

Channel 1: Direct OTP Login

Aarokya uses phone OTP -> JWT authentication. There are no passwords. Every session starts with a 6-digit OTP sent to the user’s mobile number and verified within 10 minutes.
1

Trigger OTP

Send the user’s 10-digit mobile number. In UAT the OTP is always 123456 — no SMS is sent.
curl -X POST https://api.aarokya.in/auth/otp/trigger \
  -H 'Content-Type: application/json' \
  -d '{"phone": "9876543210"}'
2

Verify OTP

Exchange the OTP for a token pair. is_new_user: true means show the onboarding flow.
curl -X POST https://api.aarokya.in/auth/otp/verify \
  -H 'Content-Type: application/json' \
  -d '{"phone": "9876543210", "otp": "123456"}'
Response includes access_token, refresh_token, and is_new_user.
3

Use the access token

Pass it as a Bearer token on every protected request.
curl https://api.aarokya.in/user/profile \
  -H 'Authorization: Bearer <access_token>'
4

Refresh before expiry

Access tokens expire after 24 hours. Use the refresh token to get a new pair silently (no re-authentication required).
curl -X POST https://api.aarokya.in/auth/token/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refresh_token": "<refresh_token>"}'

Token Reference

Access Token (JWT)

Expiry: 24 hoursFormat: JWT (HS256). Claims: user_id, exp.Usage: Authorization: Bearer <token> on every protected request.Storage: In-memory preferred; SecureEnclave/TEE for persistence.

Refresh Token (opaque)

Expiry: 30 daysFormat: Opaque 256-bit hex string (SHA-256 hashed in DB).Usage: Only for POST /auth/token/refresh.Storage: iOS Keychain / Android Keystore. Never in plaintext files or logs.

Token Storage — Per Platform

iOS

// Store refresh token in Keychain (Swift)
import Security

func saveToKeychain(key: String, value: String) {
    let data = value.data(using: .utf8)!
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: key,
        kSecValueData as String: data,
        kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
    ]
    SecItemDelete(query as CFDictionary)  // Remove existing entry
    SecItemAdd(query as CFDictionary, nil)
}

func loadFromKeychain(key: String) -> String? {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: key,
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne
    ]
    var result: AnyObject?
    SecItemCopyMatching(query as CFDictionary, &result)
    guard let data = result as? Data else { return nil }
    return String(data: data, encoding: .utf8)
}

// Usage
saveToKeychain(key: "aarokya_refresh_token", value: refreshToken)
saveToKeychain(key: "aarokya_access_token", value: accessToken)
  • Use kSecAttrAccessibleWhenUnlockedThisDeviceOnly for maximum security
  • Access tokens can also be held in memory (UserDefaults is acceptable for access tokens only if encrypted)
  • Refresh tokens MUST go in Keychain

Android

// Store refresh token in EncryptedSharedPreferences (Kotlin)
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys

val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)

val sharedPreferences = EncryptedSharedPreferences.create(
    "aarokya_secure_prefs",
    masterKeyAlias,
    context,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

// Save
sharedPreferences.edit().putString("refresh_token", refreshToken).apply()
sharedPreferences.edit().putString("access_token", accessToken).apply()

// Read
val refreshToken = sharedPreferences.getString("refresh_token", null)
val accessToken = sharedPreferences.getString("access_token", null)
  • EncryptedSharedPreferences uses Android Keystore under the hood
  • Available from API level 23 (Android 6.0+)
  • For API < 23, use the legacy KeyStore API directly

React Native

// Use react-native-keychain for secure storage
import * as Keychain from 'react-native-keychain';

// Store
await Keychain.setGenericPassword(
  'aarokya_tokens',
  JSON.stringify({ accessToken, refreshToken }),
  {
    service: 'in.aarokya.app',
    // iOS: Keychain, Android: Keystore-backed EncryptedSharedPreferences
    accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  }
);

// Load
const credentials = await Keychain.getGenericPassword({
  service: 'in.aarokya.app'
});
if (credentials) {
  const { accessToken, refreshToken } = JSON.parse(credentials.password);
}

Web (PWA / Browser)

For web applications, the recommended approach is httpOnly cookies (server-managed) rather than storing tokens in JavaScript-accessible storage:
  • Never store tokens in localStorage — vulnerable to XSS
  • Never store tokens in sessionStorage — same vulnerability
  • Preferred: Store access token in memory (JavaScript variable / React state); store refresh token in a httpOnly, Secure, SameSite=Strict cookie
// Memory-only store for web (TypeScript)
let accessToken: string | null = null;

export function setAccessToken(token: string) {
  accessToken = token;
}

export function getAccessToken(): string | null {
  return accessToken;
}

// On page refresh, user must re-authenticate or
// the server uses the httpOnly refresh token cookie to reissue

Token Rotation

Refresh tokens are single-use. Calling /auth/token/refresh:
  1. Revokes the submitted refresh token immediately (sets is_revoked = true)
  2. Issues a new access token + refresh token pair
  3. Returns 401 if the token is already revoked or expired
If the same refresh token is used twice (e.g. a race condition where two requests fire simultaneously), the second call returns 401 INVALID_TOKEN. The user must log in again with a new OTP. Implement a mutex or semaphore in your token refresh logic to prevent concurrent refresh calls.

Refresh Strategy — Client Code Examples

Implement proactive token refresh: check the access token expiry before each request and refresh 5 minutes before it expires.
interface TokenStore {
  accessToken: string;
  refreshToken: string;
  accessTokenExpiresAt: number; // Unix timestamp
}

let tokenStore: TokenStore | null = null;
let refreshPromise: Promise<TokenStore> | null = null;

async function getValidAccessToken(): Promise<string> {
  if (!tokenStore) throw new Error('Not authenticated');

  const fiveMinutesFromNow = Date.now() / 1000 + 300;

  if (tokenStore.accessTokenExpiresAt > fiveMinutesFromNow) {
    return tokenStore.accessToken; // Still valid
  }

  // Proactive refresh — deduplicate concurrent calls
  if (!refreshPromise) {
    refreshPromise = refreshTokens().finally(() => {
      refreshPromise = null;
    });
  }

  tokenStore = await refreshPromise;
  return tokenStore.accessToken;
}

async function refreshTokens(): Promise<TokenStore> {
  const response = await fetch('https://api.aarokya.in/auth/token/refresh', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ refresh_token: tokenStore!.refreshToken }),
  });

  if (!response.ok) {
    // Refresh failed — force re-login
    tokenStore = null;
    navigateToLogin();
    throw new Error('Session expired');
  }

  const data = await response.json();
  const newStore: TokenStore = {
    accessToken: data.access_token,
    refreshToken: data.refresh_token,
    accessTokenExpiresAt: data.access_token_expires_at,
  };

  // Persist to secure storage
  await saveTokensToKeychain(newStore);
  return newStore;
}

// Wrap all API calls
async function apiCall(path: string, options: RequestInit = {}) {
  const token = await getValidAccessToken();
  return fetch(`https://api.aarokya.in${path}`, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  });
}

Error Responses

HTTPError codeCause
401INVALID_OTPOTP is wrong or already used
401OTP_EXPIREDOTP session older than 10 minutes
401INVALID_TOKENAccess token expired, missing, or malformed
401MISSING_TOKENAuthorization header absent on protected endpoint
429TOO_MANY_OTP_ATTEMPTS5 wrong OTP attempts on one session
429RATE_LIMIT_EXCEEDEDOTP trigger rate limit hit

Security Checklist for Client Developers

Token storage

  • iOS: Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly
  • Android: EncryptedSharedPreferences (API 23+)
  • Web: Memory only + httpOnly cookie for refresh token
  • Never: localStorage, AsyncStorage (unencrypted), plaintext files

Token transmission

  • Always use HTTPS in production
  • Never log tokens (access or refresh)
  • Never include tokens in URLs or query parameters
  • Never send refresh tokens to any endpoint other than /auth/token/refresh

Refresh logic

  • Implement proactive refresh (5 minutes before expiry)
  • Deduplicate concurrent refresh calls with a mutex
  • On refresh failure, clear all tokens and redirect to login
  • Replace BOTH tokens atomically after a successful refresh

Session hygiene

  • Call POST /auth/logout on user-initiated logout
  • Clear all stored tokens on logout (both in memory and secure storage)
  • On app reinstall, old tokens should be treated as invalid (Keychain survives reinstall on iOS — clear on first launch after reinstall)