A Flutter developer's journey into backend development — featuring refresh tokens, UUID-based security, and serverless deployment

As a Flutter developer, I've always been comfortable building beautiful frontends. But when it came to backends? That was someone else's territory — Node.js developers, Python engineers, backend specialists. The idea of spinning up servers, managing databases, and handling authentication felt overwhelming.

Then I wanted to build my own app, from front to back. It already looked like a daunting task, until i found Dart Frog and Globe.

This is the story of how I built a production-ready expense tracker API in just a few days, complete with JWT authentication, refresh tokens, multi-device session management, and serverless deployment — all using Dart.

What We're Building

By the end of this article, you'll have:

  • A REST API with full CRUD operations
  • JWT authentication with refresh tokens
  • Multi-device session management
  • UUID-based IDs for enhanced security
  • SQLite database (serverless)
  • Globally deployed API with automatic HTTPS
  • Standardized API responses
  • Bcrypt password hashing

The Tech Stack

Dart Frog — A file-based routing framework (think Next.js for Dart). Globe — Serverless deployment platform by Invertase. Globe DB — Serverless SQLite database (also by Invertase). Drift ORM — Type-safe database queries with compile-time checks. JWT — Industry-standard authentication. Bcrypt — Secure password hashing.

Why This Stack?

Here's my honest reasoning:

I already know Dart. Learning a new language (Node.js, Python, Go) just to build a backend felt like overkill. Dart Frog let me leverage existing knowledge.

Deployment is trivial. Running globe deploy and having my API live globally in 2 minutes felt like magic.

Type safety matters. Drift's compile-time checks mean I catch database errors before deployment, not in production.

It's actually fun. File-based routing, automatic middleware composition, and zero boilerplate made this very enjoyable for my first backend experience.

Part 1: Database Design

Every good API starts with a solid database schema. Our expense tracker needs four tables:

Users — Authentication and user profiles. Categories — Predefined and custom expense categories. Expenses — The actual expense records. RefreshTokens — Multi-device session management.

Why UUIDs Instead of Auto-Increment IDs?

Most tutorials use auto-increment IDs (1, 2, 3…). I chose UUIDs for three reasons:

  1. Security — Auto-increment IDs are predictable. An attacker can enumerate users by trying /users/1, /users/2, etc.
  2. Distributed Systems — UUIDs work across multiple servers without coordination
  3. Offline Support — My Flutter app can generate IDs offline and sync later

Here's the Users table schema:

class Users extends Table {
  /// The ID of the user (UUID).
  TextColumn get id => text()();
  /// The name of the user.
  TextColumn get name => text()();
  /// The email of the user.
  TextColumn get email => text().unique()();
  /// The password hash of the user.
  TextColumn get passwordHash => text()();
  /// Timestamps
  DateTimeColumn get createdAt => dateTime()();
  DateTimeColumn get updatedAt => dateTime()();
  /// Setting the id of the user as primary key to make it unique
  @override
  Set<Column> get primaryKey => {id};
}

The RefreshTokens table is crucial for multi-device support and security:

class RefreshTokens extends Table {
  TextColumn get id => text()();
  TextColumn get userId => text()
      .references(Users, #id, onDelete: KeyAction.cascade)();
  TextColumn get tokenHash => text().unique()();
  TextColumn get deviceId => text()();
  DateTimeColumn get expiresAt => dateTime()();
  DateTimeColumn get lastUsedAt => dateTime().nullable()();
  DateTimeColumn get createdAt => dateTime()
      .withDefault(currentDateAndTime)();
  BoolColumn get isRevoked => boolean()
      .withDefault(const Constant(false))();
  @override
  Set<Column> get primaryKey => {id};
}

This table lets users:

  • Login from multiple devices simultaneously
  • See all active sessions
  • Logout from specific devices
  • Have tokens automatically expire after 30 days

Part 2: Project Setup

Let's get started with the actual implementation.

Step 1: Create the Globe Database

First, head to the Globe dashboard and:

  1. Navigate to the Databases tab
  2. Click "Create Database"
  3. Select a location close to your users
  4. Copy the auto-generated database name (e.g., gray-horst)

Step 2: Initialize Dart Frog Project

dart_frog create expense_tracker_api
cd expense_tracker_api

Step 3: Add Dependencies

dart pub add drift sqlite3 bcrypt dart_jsonwebtoken uuid crypto
dart pub add --dev drift_dev build_runner

Step 4: Define Database Schema

Create lib/database.dart with all your table definitions. Then generate the Drift code:

dart run build_runner build --delete-conflicting-outputs

This creates database.g.dart with all the type-safe query builders.

Step 5: Initialize Database Connection

@DriftDatabase(tables: [Users, Categories, Expenses, RefreshTokens])
class PocketlyDatabase extends _$PocketlyDatabase {
  PocketlyDatabase() : super(_openConnection()) {
    _initializeDatabase();
  }
  @override
  int get schemaVersion => 1;
  static QueryExecutor _openConnection() {
    return NativeDatabase.opened(
      sqlite3.open('gray-horst.db')  // Your Globe DB name
    );
  }
}

Part 3: File-Based Routing

This is where Dart Frog shines. No route registration, no complex setup — just create files and they become endpoints.

Here's my project route structure:

routes/
├── _middleware.dart         # Global CORS
├── auth/
│   ├── _middleware.dart     # Provides DB to auth routes
│   ├── register.dart        # POST /auth/register
│   ├── login.dart           # POST /auth/login
│   ├── refresh.dart         # POST /auth/refresh
│   └── logout.dart          # POST /auth/logout
├── expenses/
│   ├── _middleware.dart     # Auth + DB provider
│   ├── index.dart           # GET/POST /expenses
│   └── [id].dart            # GET/PATCH/DELETE /expenses/:id
└── categories/
    ├── _middleware.dart     # Auth + DB provider
    └── index.dart           # GET/POST /categories

No manual routing needed. The file structure IS the routing.

Dynamic routes use square brackets: [id].dart becomes /expenses/:id

Part 4: Standardized API Responses

One lesson I learned quickly: inconsistent API responses make frontend development painful. I created a utility class to ensure every endpoint returns the same structure:

class ApiResponse {
  static Response success({
    required dynamic data,
    String? message,
    int statusCode = HttpStatus.ok,
  }) {
    return Response.json(
      statusCode: statusCode,
      body: {
        'success': true,
        'message': message,
        'data': data,
      },
    );
  }
 static Response created({
    required dynamic data,
    String? message,
  }) {
    return Response.json(
      statusCode: HttpStatus.created,
      body: {
        'success': true,
        'message': message ?? 'Resource created successfully',
        'data': data,
      },
    );
  }
  static Response badRequest({
    required String message,
    dynamic errors,
  }) {
    return Response.json(
      statusCode: HttpStatus.badRequest,
      body: {
        'success': false,
        'message': message,
        'errors': errors,
      },
    );
  }
  // ... more methods for 401, 403, 404, 409, 500
}

Now every response looks like this:

{
  "success": true,
  "message": "User registered successfully",
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "John Doe",
    "email": "john@example.com"
  }
}

Clean. Consistent. Easy to parse on the frontend.

Part 5: User Registration

The registration endpoint showcases several best practices:

Future<Response> _register(RequestContext context) async {
  final db = context.read<PocketlyDatabase>();
  try {
    final body = await context.request.json() as Map<String, dynamic>;
    final email = body['email'] as String?;
    final password = body['password'] as String?;
    final name = body['name'] as String?;
    // Validation
    if (email == null || password == null || name == null) {
      return ApiResponse.badRequest(
        message: 'Email, name, and password are required',
      );
    }
    if (password.length < 8) {
      return ApiResponse.badRequest(
        message: 'Password must be at least 8 characters',
      );
    }
    // Check for existing user
    final existingUser = await (db.select(db.users)
      ..where((u) => u.email.equals(email))).getSingleOrNull();
    if (existingUser != null) {
      return ApiResponse.conflict(
        message: 'User with this email already exists',
      );
    }
    // Hash password with bcrypt
    final passwordHash = BCrypt.hashpw(password, BCrypt.gensalt());
    // Create user
    final userId = _uuid.v4();
    final now = DateTime.now();
    
    final newUser = await db.into(db.users).insertReturning(
      UsersCompanion.insert(
        id: Value(userId),
        name: name,
        email: email,
        passwordHash: passwordHash,
        createdAt: now,
        updatedAt: now,
      ),
    );
    return ApiResponse.created(
      message: 'User registered successfully',
      data: {
        'id': newUser.id,
        'name': newUser.name,
        'email': newUser.email,
        'createdAt': newUser.createdAt.toIso8601String(),
      },
    );
  } on SqliteException catch (e) {
    if (e.message.contains('UNIQUE constraint')) {
      return ApiResponse.conflict(
        message: 'User with this email already exists',
      );
    }
    return ApiResponse.internalError(
      message: 'Database error occurred',
    );
  } catch (e) {
    return ApiResponse.internalError(
      message: 'Failed to register user',
    );
  }
}

Key points:

  1. Input validation happens first
  2. Bcrypt handles password hashing (never store plain text!)
  3. UUIDs are generated explicitly
  4. Specific error handling for database constraints
  5. DateTime objects converted to ISO8601 strings (crucial for JSON!)

Part 6: JWT Authentication with Refresh Tokens

Most tutorials stop at basic JWT authentication. But production apps need refresh tokens. Here's why:

Access Tokens — Short-lived (15 minutes), used for API requests Refresh Tokens — Long-lived (30 days), used to get new access tokens

This dual-token system provides:

  • Better security — If an access token is stolen, it expires quickly
  • Multi-device support — Each device gets its own refresh token
  • Granular control — Can revoke specific devices
  • Session management — Users can see where they're logged in

Token Helper Utility

class TokenHelper {
  static String generateAccessToken(String userId, String email) {
    final jwtSecret = Platform.environment['JWT_SECRET_KEY']!;
    
    final jwt = JWT({
      'userId': userId,
      'email': email,
      'type': 'access',
    });
  return jwt.sign(
      SecretKey(jwtSecret),
      expiresIn: const Duration(minutes: 15),
    );
  }
  static String generateRefreshToken() {
    return _uuid.v4();
  }
  static String hashToken(String token) {
    final bytes = utf8.encode(token);
    final digest = sha256.convert(bytes);
    return digest.toString();
  }
}

Login Endpoint

Future<Response> _login(RequestContext context) async {
  final db = context.read<PocketlyDatabase>();
  try {
    final body = await context.request.json() as Map<String, dynamic>;
    final email = body['email'] as String?;
    final password = body['password'] as String?;
    final deviceId = body['deviceId'] as String? ?? 'Unknown Device';
    // Find user and verify password
    final user = await (db.select(db.users)
      ..where((u) => u.email.equals(email))).getSingleOrNull();
    if (user == null || !BCrypt.checkpw(password, user.passwordHash)) {
      return ApiResponse.unauthorized(
        message: 'Invalid email or password',
      );
    }
    // Generate tokens
    final accessToken = TokenHelper.generateAccessToken(
      user.id, user.email
    );
    final refreshToken = TokenHelper.generateRefreshToken();
    final refreshTokenHash = TokenHelper.hashToken(refreshToken);
    // Store hashed refresh token
    await db.into(db.refreshTokens).insert(
      RefreshTokensCompanion.insert(
        id: Value(_uuid.v4()),
        userId: user.id,
        tokenHash: refreshTokenHash,
        deviceId: deviceId,
        expiresAt: DateTime.now().add(const Duration(days: 30)),
      ),
    );
    return ApiResponse.success(
      message: 'Login successful',
      data: {
        'accessToken': accessToken,
        'refreshToken': refreshToken,  // Send plain token to client
        'user': {
          'id': user.id,
          'name': user.name,
          'email': user.email,
        },
      },
    );
  } catch (e) {
    return ApiResponse.internalError(
      message: 'Login failed',
    );
  }
}

Security note: We hash refresh tokens before storing them. Even if the database is compromised, the tokens are useless.

Refresh Token Endpoint

When the access token expires, the client uses the refresh token to get a new one:

Future<Response> _refreshToken(RequestContext context) async {
  final db = context.read<PocketlyDatabase>();
  try {
    final body = await context.request.json() as Map<String, dynamic>;
    final refreshToken = body['refreshToken'] as String?;
    if (refreshToken == null) {
      return ApiResponse.badRequest(
        message: 'Refresh token is required',
      );
    }
    // Hash and find token
    final tokenHash = TokenHelper.hashToken(refreshToken);
    final storedToken = await (db.select(db.refreshTokens)
      ..where((t) => t.tokenHash.equals(tokenHash))).getSingleOrNull();
    if (storedToken == null || storedToken.isRevoked) {
      return ApiResponse.unauthorized(
        message: 'Invalid refresh token',
      );
    }
    // Check expiry
    if (storedToken.expiresAt.isBefore(DateTime.now())) {
      await (db.delete(db.refreshTokens)
        ..where((t) => t.id.equals(storedToken.id))).go();
      
      return ApiResponse.unauthorized(
        message: 'Refresh token has expired',
      );
    }
    // Get user and generate new access token
    final user = await (db.select(db.users)
      ..where((u) => u.id.equals(storedToken.userId))).getSingle();
    // Update last used timestamp
    await (db.update(db.refreshTokens)
      ..where((t) => t.id.equals(storedToken.id))).write(
      RefreshTokensCompanion(
        lastUsedAt: Value(DateTime.now()),
      ),
    );
    final newAccessToken = TokenHelper.generateAccessToken(
      user.id, user.email
    );
    return ApiResponse.success(
      message: 'Token refreshed successfully',
      data: {'accessToken': newAccessToken},
    );
  } catch (e) {
    return ApiResponse.internalError(
      message: 'Token refresh failed',
    );
  }
}

Part 7: Protecting Routes with Middleware

Dart Frog's middleware system is beautiful. Instead of adding authentication checks to every endpoint, I create a middleware file that protects entire route sections:

// routes/expenses/_middleware.dart
Handler middleware(Handler handler) {
  return handler
      .use(provider<PocketlyDatabase>((_) => _db))
      .use(_authMiddleware());
}
Middleware _authMiddleware() {
  return (handler) {
    return (context) async {
      final authHeader = context.request.headers['authorization'];
      
      if (authHeader == null) {
        return ApiResponse.unauthorized(
          message: 'Missing authorization token',
        );
      }
      final token = authHeader.replaceFirst('Bearer ', '');
      final jwtSecret = Platform.environment['JWT_SECRET_KEY']!;
      try {
        final jwt = JWT.verify(token, SecretKey(jwtSecret));
        final userId = jwt.payload['userId'] as String;
        
        // Add userId to context for use in routes
        return await handler(
          context.provide<String>(() => userId),
        );
      } on JWTException catch (e) {
        return ApiResponse.unauthorized(
          message: 'Invalid token: ${e.message}',
        );
      }
    };
  };
}

Now every route under /expenses is automatically protected. No boilerplate in individual endpoints!

Part 8: CRUD Operations

With authentication in place, the actual CRUD operations are straightforward. Here's the expenses endpoint:

Future<Response> _getExpenses(RequestContext context) async {
  final db = context.read<PocketlyDatabase>();
  final userId = context.read<String>();  // From auth middleware!
  
  try {
    // Only fetch expenses belonging to this user
    final expenses = await (db.select(db.expenses)
      ..where((e) => e.userId.equals(userId))
      ..orderBy([(e) => OrderingTerm.desc(e.date)])).get();
    
    return ApiResponse.success(
      message: 'Expenses retrieved successfully',
      data: expenses.map((expense) => {
        'id': expense.id,
        'name': expense.name,
        'amount': expense.amount,
        'date': expense.date.toIso8601String(),
        'categoryId': expense.categoryId,
      }).toList(),
    );
  } catch (e) {
    return ApiResponse.internalError(
      message: 'Failed to retrieve expenses',
    );
  }
}

Notice how we use context.read<String>() to get the authenticated user's ID. The middleware already verified the token and provided the userId — we just use it.

Part 9: Deployment to Globe

This is where everything comes together. After all the local development and testing, deploying is absurdly simple.

Step 1: Link Your Project

globe link

This command guides you through creating a new project on Globe. It automatically detects your Dart Frog project and configures everything.

Step 2: Set Environment Variables

In the Globe dashboard:

  1. Go to Settings → Environment Variables
  2. Add JWT_SECRET_KEY with a long, random string
  3. The database connection is automatically configured!

Step 3: Deploy

globe deploy

That's it. Seriously.

In about 2 minutes, you get:

  • ✅ Your API deployed globally
  • ✅ Automatic HTTPS
  • ✅ DDoS protection
  • ✅ Auto-scaling
  • ✅ Database connected
  • ✅ A custom URL like https://your-project.globe.dev

No Docker. No Kubernetes. No nginx configuration. No SSL certificate management. Just globe deploy.

Part 10: Testing the Live API

Let's verify everything works:

Register a User

curl -X POST https://your-api.globe.dev/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Doe",
    "email": "john@example.com",
    "password": "password123"
  }'

Response:

{
  "success": true,
  "message": "User registered successfully",
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "John Doe",
    "email": "john@example.com",
    "createdAt": "2025-01-15T10:30:00.000Z"
  }
}

Login

curl -X POST https://your-api.globe.dev/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "john@example.com",
    "password": "password123",
    "deviceId": "Chrome on MacOS"
  }'

Save both the accessToken and refreshToken from the response.

Create an Expense

curl -X POST https://your-api.globe.dev/expenses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -d '{
    "name": "Lunch at Chipotle",
    "amount": 15.50,
    "date": "2025-01-15T12:00:00Z",
    "categoryId": "food-category-uuid"
  }'

Refresh Token After 15 Minutes

curl -X POST https://your-api.globe.dev/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{
    "refreshToken": "YOUR_REFRESH_TOKEN"
  }'

You get a new access token without needing to login again!

Lessons Learned

Building this API taught me several valuable lessons:

1. DateTime Serialization is Tricky

I spent an hour debugging 500 errors before realizing DateTime objects can't be serialized to JSON directly:

// ❌ This breaks
return { 'createdAt': DateTime.now() };
// ✅ This works
return { 'createdAt': DateTime.now().toIso8601String() };

Always convert DateTime to strings!

2. Database Constraints Are Your Friend

I initially forgot to add a UNIQUE constraint on the email field. A test user registered twice with the same email, breaking my assumptions. Database constraints caught this before it became a production issue.

3. HTTP Status Codes Matter

Using proper status codes (201 for created, 409 for conflict, etc.) makes your API dramatically easier to consume. Frontend developers appreciate semantic responses.

4. Middleware Composition is Powerful

Instead of repeating authentication logic in every endpoint, Dart Frog's middleware system let me protect entire route sections with a single file. Clean and maintainable.

5. Refresh Tokens Are Worth It

Implementing refresh tokens added maybe 2 hours of work but provides:

  • Multi-device login
  • Better security
  • Session management
  • The ability to revoke specific devices

Absolutely worth the effort.

What's Next?

This API is production-ready, but there's always room for improvement:

  • Email verification
  • Password reset flow
  • Rate limiting
  • Request validation middleware
  • Automated tests
  • API documentation
  • Budget tracking and analytics
  • Receipt image upload

Final Thoughts

Building a backend doesn't have to mean learning a completely new language and ecosystem. If you're a Flutter developer, you already know Dart — and that's enough.

The combination of:

  • Dart Frog's file-based routing and middleware system
  • Globe's deployment simplicity
  • Drift's type-safe queries

…made this the smoothest backend development experience I've had.

Total development time: 4 days Deployment time: 2 minutes Cost: Free tier covers everything for development

If you're a Flutter developer who's been hesitant about backend development, I encourage you to try this stack. Start small, deploy often, and don't be intimidated — you've got this.

Resources

Found this helpful? Give it a clap and follow me for more Dart/Flutter content!

Questions? Drop them in the comments below — I read and respond to every one.

Building something similar? I'd love to hear about it! Tag me or share your experience.

Happy coding! 🚀