Overview
MechZie uses Firebase Phone Authentication for identity verification and issues its own JWT access + refresh token pair for API authorization. The frontend never sends Firebase tokens after the initial exchange.
Auth Flow
Send OTP via Firebase
Use the Firebase Auth SDK in your Flutter app to send an OTP to the user’s phone number. The MechZie API is not involved in this step.await FirebaseAuth.instance.verifyPhoneNumber(
phoneNumber: '+919876543210',
verificationCompleted: (credential) async {
await FirebaseAuth.instance.signInWithCredential(credential);
},
verificationFailed: (e) => handleError(e),
codeSent: (verificationId, resendToken) {
// Store verificationId for Step 2
},
codeAutoRetrievalTimeout: (verificationId) {},
);
Verify OTP and get Firebase ID Token
After the user enters the OTP code, verify it and extract the Firebase ID token.final credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: userEnteredCode,
);
final userCredential = await FirebaseAuth.instance.signInWithCredential(credential);
final firebaseIdToken = await userCredential.user!.getIdToken();
Exchange for MechZie tokens
Send the Firebase ID token to the MechZie API to get your JWT pair.curl -X POST https://mechzie-api-production.run.app/api/v1/auth/verify-otp \
-H "Content-Type: application/json" \
-d '{
"firebaseIdToken": "eyJhbGciOiJSUzI1NiIs...",
"role": "customer"
}'
Response (200):{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "a1b2c3d4e5f6...",
"user": {
"id": "uuid-here",
"phone": "+919876543210",
"name": null,
"email": null,
"role": "customer",
"avatar_url": null,
"created_at": "2026-05-21T10:00:00.000Z"
}
}
The role field must be customer or mechanic. If the user already exists with a different role, the API returns 403 ROLE_CONFLICT. Users cannot switch roles.
Store tokens securely
Store both tokens in secure storage (e.g. flutter_secure_storage). Never store in SharedPreferences or local storage.await secureStorage.write(key: 'accessToken', value: response.accessToken);
await secureStorage.write(key: 'refreshToken', value: response.refreshToken);
Using the Access Token
Include the access token in all authenticated API requests:
final response = await http.get(
Uri.parse('$baseUrl/api/v1/users/me'),
headers: {'Authorization': 'Bearer $accessToken'},
);
For Socket.io connections, pass it in the handshake:
final socket = io('$baseUrl', OptionBuilder()
.setTransports(['websocket'])
.setAuth({'token': accessToken})
.build()
);
Token Refresh
Access tokens expire after 15 minutes. Refresh tokens are valid for 30 days. When the access token expires, use the refresh token to get a new pair:
curl -X POST https://mechzie-api-production.run.app/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "a1b2c3d4e5f6..."
}'
Response (200):
{
"accessToken": "new-access-token...",
"refreshToken": "new-refresh-token..."
}
Token rotation is enforced. The old refresh token is revoked immediately. You must store the new refresh token from each response. Using a revoked refresh token returns 401 and requires full re-authentication via Firebase.
Recommended: Interceptor Pattern
class AuthInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
try {
final newTokens = await refreshTokens();
// Retry the original request with new access token
final retryResponse = await retry(err.requestOptions, newTokens.accessToken);
return handler.resolve(retryResponse);
} catch (e) {
// Refresh failed — redirect to Firebase login
await redirectToLogin();
}
}
return handler.next(err);
}
}
Logout
Revoke a specific refresh token. The client should discard both tokens.
curl -X POST https://mechzie-api-production.run.app/api/v1/auth/logout \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "a1b2c3d4e5f6..."
}'
This only revokes the single refresh token sent. Other devices remain logged in. Suspension (by admin) revokes all refresh tokens for the user.
JWT Access Token Payload
The access token is an HS256 JWT with this payload:
{
"sub": "user-uuid",
"role": "customer",
"firebaseUid": "firebase-uid-string",
"type": "access",
"iat": 1716300000,
"exp": 1716300900
}
| Field | Type | Description |
|---|
sub | UUID | The user’s ID in MechZie |
role | string | customer, mechanic, admin, or super_admin |
firebaseUid | string | Firebase Auth UID |
type | string | Always access for access tokens |
iat | number | Issued at (Unix timestamp) |
exp | number | Expires at (15 min after iat) |
Admin Invite Flow
Admin accounts are invite-only. They cannot be created via verify-otp.
- A
super_admin creates an invite → POST /api/v1/super-admin/invites with { email, role }
- An invite link is sent to the email
- The invitee signs up with Firebase (email must match) and calls:
curl -X POST https://mechzie-api-production.run.app/api/v1/auth/complete-admin-invite \
-H "Content-Type: application/json" \
-d '{
"firebaseIdToken": "eyJhbGci...",
"inviteToken": "invite-token-from-email"
}'
The role is always taken from the invite, never from the request body.
User Roles
| Role | How to get it | Capabilities |
|---|
customer | verify-otp with role: "customer" | Create jobs, manage vehicles, make payments |
mechanic | verify-otp with role: "mechanic" | Accept jobs, update location, add line items |
admin | Invite only | Dashboard, verify mechanics, manage jobs, refunds |
super_admin | Invite only | Everything admin can do + invite management + role changes |
A user registered as customer cannot later authenticate as mechanic (or vice versa). The API returns 403 ROLE_CONFLICT. Each phone number is locked to one role.
Error Responses
| Scenario | HTTP | Error Code |
|---|
| Invalid/expired Firebase token | 401 | UNAUTHORIZED |
| Invalid/expired refresh token | 401 | UNAUTHORIZED |
| Missing Authorization header | 401 | UNAUTHORIZED |
| User suspended by admin | 401 | UNAUTHORIZED |
| Role mismatch (customer trying mechanic) | 403 | ROLE_CONFLICT |
| Rate limited (too many OTP exchanges) | 429 | RATE_LIMIT_EXCEEDED |