Overview
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.
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"}'
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.
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>'
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.
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:
Revokes the submitted refresh token immediately (sets is_revoked = true)
Issues a new access token + refresh token pair
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.
TypeScript / React Native
Kotlin (Android)
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
HTTP Error code Cause 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 endpoint429TOO_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)