Skip to main content

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

1

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) {},
);
2

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();
3

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

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.
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
}
FieldTypeDescription
subUUIDThe user’s ID in MechZie
rolestringcustomer, mechanic, admin, or super_admin
firebaseUidstringFirebase Auth UID
typestringAlways access for access tokens
iatnumberIssued at (Unix timestamp)
expnumberExpires at (15 min after iat)

Admin Invite Flow

Admin accounts are invite-only. They cannot be created via verify-otp.
  1. A super_admin creates an invite → POST /api/v1/super-admin/invites with { email, role }
  2. An invite link is sent to the email
  3. 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

RoleHow to get itCapabilities
customerverify-otp with role: "customer"Create jobs, manage vehicles, make payments
mechanicverify-otp with role: "mechanic"Accept jobs, update location, add line items
adminInvite onlyDashboard, verify mechanics, manage jobs, refunds
super_adminInvite onlyEverything 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

ScenarioHTTPError Code
Invalid/expired Firebase token401UNAUTHORIZED
Invalid/expired refresh token401UNAUTHORIZED
Missing Authorization header401UNAUTHORIZED
User suspended by admin401UNAUTHORIZED
Role mismatch (customer trying mechanic)403ROLE_CONFLICT
Rate limited (too many OTP exchanges)429RATE_LIMIT_EXCEEDED