Featured image of post JWT Authentication in Flutter – Tutorial & Follow-Along

JWT Authentication in Flutter – Tutorial & Follow-Along

In this tutorial, I show you how to implement JWT authentication in Flutter including refresh-token handling.

You can find the source code for this tutorial on GitHub: https://github.com/bettercoding-dev/flutter-jwt-auth.

If you want to code some parts of the app yourself, checkout the follow-along branch. There, I removed some code, so you can implement it yourself. This will help you to understand better and improve your coding skills.

What is JWT authentication?

Before we start, let us have a quick look at what JWT even means. JWT stands for JSON Web Tokens. It is basically a JSON object encoded into a base64 string and signed with a secret key.

The token is issued and signed by a server and sent to a client after a successful login. Now the client can include this token in all its requests to the server. The server can then take a look at the token and verify that it has not expired and that the signature matches.

You can think of it as a ticket for an amusement park or public transport. You show it to someone, and they will verify that the ticket has not expired and that the ticket is not fake. If everything is fine, you can use the service.

JWT refresh tokens

To prevent users from being signed out every time the token expires, it is common for the server to issue two types of tokens: a token used for authenticate the calls, and a refresh token, that can be used to ask the server for a new token.

You can learn more about JWT on https://jwt.io.

You can think of the refresh token as a second way to login. Instead of sending your credentials to the server, the refresh token is used. But in contrast to a normal login, the token refresh takes place in the background and the user will not notice it.

In this tutorial, we will set up our Flutter app to use tokens for authentication, and request new tokens using refresh tokens.

Prerequisites

The app that we are creating in this tutorial will be relatively simple. It will allow the user to log in and log out and to display the current server time in the app. The request for fetching the server time is authenticated and is only allowed for logged-in users.

Before we start, download the project from: https://github.com/bettercoding-dev/flutter-jwt-auth.

Let’s have a look at our project structure. It uses the same architecture as explained in this tutorial.

If you have checked out the code from the repo, you can see the following folders and files in the lib folder.

  • main.dart: the entry point of the app
  • global_providers.dart: globally needed riverpod providers are created here, especially the initialization of Dio will be of interest here
  • time: includes all files needed for fetching the time and displaying it in the app
  • common: non-feature-specific files
  • auth: authentication-specific files.
    • client: implementation of authentication REST calls login and refresh.
    • interceptor: contains AuthInterceptor that we will use to authenticate our calls and implement refresh handling
    • model: contains a simple model AuthData containing both tokens
    • repository: abstraction layer that handles authentication-specific operations
    • state: contains AuthController, which handles authentication-specific state.

We will focus on implementing the AuthInterceptor and the AuthController.

The test server

To actually test our implementation, the source code also contains a small server that issues tokens and includes an authenticated API-Endpoint.

You can start it with the following command:

dart server/shelf-jwt-test-server/bin/server.dart

This will run the server on localhost at port 8080.

If you are using the iOS Simulator, that you are good to go. For the Android Emulator, you will need to change the baseUrl of the API to 10.0.2.2. If you are using a real device, you will need to replace the IP with the address of your computer.

You can change the baseUrl in global_providers.dart.

I built the server using the shelf package. You can also check out a standalone version of the server on Github: https://github.com/bettercoding-dev/shelf-jwt-test-server.

Implementing AuthController

First, let us implement the AuthController. It’s responsible for holding and manipulating authentication-specific state.

In this example, it is implemented as a riverpod Notifier. This means we need to implement the build method first.

Use dart run build_runner watch --delete-conflicting-outputs to regenerate code.

build

The build method is responsible to initialize the state. For us, this means to check our storage if there are any stored tokens available and update the state accordingly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@override
Future<AuthData?> build() async {
  final storageRepository = ref.watch(storageRepositoryProvider);

  final token = await storageRepository.getToken();
  final refreshToken = await storageRepository.getRefreshToken();

  if (token != null && refreshToken != null) {
    return AuthData(token: token, refreshToken: refreshToken);
  } else {
    return null;
  }
}

The StorageRepository uses the flutter_secure_storage package to safely store the tokens on the device.

login

Next, let us implement the login. In this simplified example, we only take a username to log in. Our test server does not care about a password.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Future<void> login(String username) async {
  final authRepository = ref.read(authRepositoryProvider);
  final storageRepository = ref.read(storageRepositoryProvider);

  state = await AsyncValue.guard(() async {
    final authData = await authRepository.login(username);
    await storageRepository.storeToken(authData.token);
    await storageRepository.storeRefreshToken(authData.refreshToken);
    return authData;
  });
}

We are initiating a login call using the AuthRepository. If everything works as expected, AuthData will be returned. We store the received data in using the StorageRepository and set our state.

The AsyncValue.guard function is a cool helper from riverpod. If everything works as expected, a AsyncData object ist returned. If an error is thrown inside the guarded function, an AsyncError is returned.

refreshToken

The refreshToken function is very similar to the login. But instead of using the username to authenticate, we check if a refreshToken is available and use that one instead. In case of success, we update the state with the new tokens.

If the token refresh fails (most likely because the refresh token expired as well), we are going to log out the user.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Future<void> refreshToken() async {
  final authRepository = ref.read(authRepositoryProvider);
  final storageRepository = ref.read(storageRepositoryProvider);
  final authData = state.valueOrNull;
  if (authData != null) {
    try {
      final newTokens =
      await authRepository.refreshToken(authData.refreshToken);
      storageRepository.storeToken(newTokens.token);
      storageRepository.storeRefreshToken(newTokens.refreshToken);
      state = AsyncData(newTokens);
    } on DioException {
      await logout();
      rethrow;
    }
  }
}

logout

Logging out using JWT is pretty straightforward. All we need to do is to remove our tokens from the storage and clear the state;

1
2
3
4
5
6
7
8
Future<void> logout() async {
  state = await AsyncValue.guard(() async {
    final storageRepository = ref.read(storageRepositoryProvider);
    await storageRepository.deleteToken();
    await storageRepository.deleteRefreshToken();
    return null;
  });
}

Implementing AuthInterceptor

Interceptors are a way to add functionality to Dio when sending requests, receiving responses or handling errors. The functions of an interceptor are called every time one of these three events occurs, and allow to make modifications to the HTTP calls.

Adding Interceptors

The interceptors can be added when creating a new instance of Dio. In our case, this is done in the global_providers.dart file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@riverpod
Dio dio(DioRef ref) {
  final dio = Dio(
    BaseOptions(
      baseUrl: 'http://localhost:8080',
      contentType: Headers.jsonContentType,
    ),
  );

  dio.interceptors.add(PrettyDioLogger());
  dio.interceptors.add(AuthInterceptor(ref, dio));

  return dio;
}

We add two interceptors here. The PrettyDioLogger will print all requests and responses to the console. This helps debug in case of errors.

Secondly, we add our AuthInterceptor to the interceptors list.

Adding the Authorization header

When you open the auth_interceptor.dart file, you can see that there are two functions we would like to override.

For adding JWT to outgoing requests, the onRequest function is the one we want to implement.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
  final authData = await ref.read(authControllerProvider.future);
  final token = authData?.token;
  if (token != null) {
    options.headers['Authorization'] = 'Bearer $token';
  }

  super.onRequest(options, handler);
}

It’s actually not too complicated. We first check the state of the AuthController, if a token is available. If not, we cannot do anything and just leave the request as it is.

In case we have an available token, we add it as an Authorization header, as you can see in line 6. Remember to add Bearer in front of your token.

Now you can start the app and click the login button. You should se the token and the refresh token values, as well as the current server time displayed on the screen.

If you click on “Refresh” a new request will be sent to the server asking for an updated time. This will work as long as your token is valid (15 seconds in this case). After that, you have to log in again to get a new token.

flutter-jwt-logged-out.png flutter-jwt-logged-in.png

Implement Token Refresh

Since it would be annoying to log in again every time the token expires, let us use the refresh token to update our tokens.

We are going to do that by implementing the onError function of the interceptor.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
  final isRetry = err.requestOptions.extra['isRetry'] == true;

  // if response is unauthorized
  if (err.response?.statusCode == 401 && !isRetry) {
    try {
      final authData = await ref.read(authControllerProvider.future);
      final refreshToken = authData?.refreshToken;

      if (refreshToken != null) {
        await ref.read(authControllerProvider.notifier).refreshToken();

        final options = err.requestOptions;
        options.extra['isRetry'] = true;
        final response = await dio.fetch(options);

        handler.resolve(response);
      } else {
        super.onError(err, handler);
      }
    } on DioException catch (error, trace) {
      log('cannot refresh', error: error, stackTrace: trace);
      super.onError(error, handler);
    }
  } else {
    super.onError(err, handler);
  }
}
  • Line 3: Here we are checking for an extra called isRetry. This is a flag that we set below in line 15 to mark a request when we retry to get data from the server after updating the tokens. That way, we avoid endless loops when retrying failed requests over and over again.

  • Line 6: We check if the request failed because it was unauthorized, and if we haven’t retried this request already.

  • Lines 8–11: We get hold of our refresh token. If we do not have a refresh token, we cannot refresh and simply let the call fail (line 20).

  • Line 12: We call the AuthController to refresh the tokens. If that throws a DioException we fail the call as well (line 24).

  • Lines 14–18: After refreshing the tokens, we give the request another go. After setting the isRetry extra, we use dio.fetch to execute the request once again. If it successfully returns a response, we use the response to resolve the error.

Try the token refresh

Now, when we start our app and log in, you can now keep pressing “Refresh” although the token has expired. If you check the logs, you can see that in the background, the app has been refreshing the tokens.

Notice that the refresh token can expire. If this is the case, you get logged out.

The server log with refresh calls.

The log of the Flutter app.

Summary

In this tutorial, we have learned how to set up a Dio interceptor to use JSON Web Tokens to authenticate against an API.

We used a refresh token to prevent the user from being logged out continuously, but instead refresh the authentication token instead.

The tokens are stored in an encrypted storage, and we use riverpod Notifier to keep track of the authentication state.

Built with Hugo
Theme based on Stack designed by Jimmy