This tutorial builds a small but complete Flutter authentication slice for JSON API User Plus 5.0.4. The screen accepts an email or username and password, posts them to WordPress, stores the returned JWT securely, and opens a protected home screen.
The important API detail is that User Plus returns the JWT in token. Do not parse access_token.
Before you start
On WordPress:
- Install and activate RESTful JSON API.
- Install JSON API User Plus 5.0.4.
- Activate the User Plus license and controller.
- Confirm
https://your-site.com/api/userplus/info/responds. - Use a real HTTPS domain. The login controller enforces HTTPS.
Add the Flutter packages:
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
flutter_secure_storage: ^9.2.0
Resolve packages with flutter pub get. Use current compatible versions in your project; the constraints above are illustrative.
Create the session model
class UserPlusSession {
const UserPlusSession({
required this.token,
required this.expires,
required this.userId,
required this.displayName,
});
final String token;
final int expires;
final int userId;
final String displayName;
factory UserPlusSession.fromJson(Map<String, dynamic> json) {
final user = json[‘user’] as Map<String, dynamic>?;
final token = json[‘token’] as String?;
if (token == null || token.isEmpty || user == null) {
throw const FormatException(‘Invalid login response’);
}
return UserPlusSession(
token: token,
expires: (json[‘expires’] as num?)?.toInt() ?? 0,
userId: (user[‘id’] as num).toInt(),
displayName: user[‘display_name’] as String? ?? ”,
);
}
}
Build the API client
import ‘dart:convert’;
import ‘package:http/http.dart’ as http;
class LoginFailure implements Exception {
const LoginFailure(this.message);
final String message;
}
class UserPlusApi {
UserPlusApi({required this.baseUrl, http.Client? client})
: _client = client ?? http.Client();
final String baseUrl;
final http.Client _client;
Uri _uri(String endpoint) =>
Uri.parse(‘$baseUrl/api/userplus/$endpoint/’);
Future<UserPlusSession> login({
required String username,
required String password,
}) async {
late http.Response response;
try {
response = await _client
.post(
_uri(‘login’),
headers: const {‘Accept’: ‘application/json’},
body: {‘username’: username.trim(), ‘password’: password},
)
.timeout(const Duration(seconds: 20));
} on Exception {
throw const LoginFailure(‘Unable to reach the server. Try again.’);
}
Map<String, dynamic> body;
try {
body = jsonDecode(response.body) as Map<String, dynamic>;
} on FormatException {
throw const LoginFailure(‘The server returned an invalid response.’);
}
if (response.statusCode == 401) {
throw const LoginFailure(‘Email or password is incorrect.’);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw LoginFailure(
body[‘error’] as String? ?? ‘Login failed. Try again.’,
);
}
try {
return UserPlusSession.fromJson(body);
} on FormatException {
throw const LoginFailure(‘The login response was incomplete.’);
}
}
Future<bool> validateToken(String token) async {
final response = await _client.get(
_uri(‘validate_token’),
headers: {
‘Accept’: ‘application/json’,
‘Authorization’: ‘Bearer $token’,
},
).timeout(const Duration(seconds: 20));
if (response.statusCode == 401) return false;
final body = jsonDecode(response.body) as Map<String, dynamic>;
return response.statusCode == 200 && body[‘valid’] == true;
}
}
Do not print the password, response token, or full Authorization header while debugging.
Store the JWT securely
import ‘package:flutter_secure_storage/flutter_secure_storage.dart’;
class SessionStore {
const SessionStore(this._storage);
final FlutterSecureStorage _storage;
static const _tokenKey = ‘user_plus_jwt’;
Future<void> save(String token) =>
_storage.write(key: _tokenKey, value: token);
Future<String?> read() => _storage.read(key: _tokenKey);
Future<void> clear() => _storage.delete(key: _tokenKey);
}
flutter_secure_storage delegates to platform security facilities. Configure its iOS Keychain and Android backup/security behavior according to the package’s current documentation and your threat model.
Create the login screen
import ‘package:flutter/material.dart’;
class LoginScreen extends StatefulWidget {
const LoginScreen({
super.key,
required this.api,
required this.store,
required this.onLoggedIn,
});
final UserPlusApi api;
final SessionStore store;
final VoidCallback onLoggedIn;
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _username = TextEditingController();
final _password = TextEditingController();
bool _busy = false;
bool _obscurePassword = true;
String? _error;
@override
void dispose() {
_username.dispose();
_password.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_busy || !_formKey.currentState!.validate()) return;
setState(() {
_busy = true;
_error = null;
});
try {
final session = await widget.api.login(
username: _username.text,
password: _password.text,
);
await widget.store.save(session.token);
if (mounted) widget.onLoggedIn();
} on LoginFailure catch (error) {
if (mounted) setState(() => _error = error.message);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(‘Sign in’, style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 8),
const Text(‘Use your WordPress account.’),
const SizedBox(height: 24),
TextFormField(
controller: _username,
enabled: !_busy,
autofillHints: const [AutofillHints.username],
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: ‘Email or username’,
border: OutlineInputBorder(),
),
validator: (value) => value == null || value.trim().isEmpty
? ‘Enter your email or username.’
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _password,
enabled: !_busy,
obscureText: _obscurePassword,
autofillHints: const [AutofillHints.password],
onFieldSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: ‘Password’,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
icon: Icon(_obscurePassword
? Icons.visibility
: Icons.visibility_off),
),
),
validator: (value) => value == null || value.isEmpty
? ‘Enter your password.’
: null,
),
if (_error != null) …[
const SizedBox(height: 16),
Semantics(
liveRegion: true,
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _busy ? null : _submit,
child: _busy
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text(‘Sign in’),
),
],
),
),
),
),
),
),
);
}
}
The screen prevents double submission, supports keyboard actions and autofill, gives the password a visibility toggle, announces errors to assistive technology, and checks mounted after asynchronous work.
Restore the session at startup
Future<bool> restoreSession(UserPlusApi api, SessionStore store) async {
final token = await store.read();
if (token == null) return false;
try {
final valid = await api.validateToken(token);
if (!valid) await store.clear();
return valid;
} on Exception {
// Decide whether offline startup may use a previously validated local session.
return false;
}
}
Do not route to Home merely because a token exists. validate_token verifies it on WordPress and returns the authenticated user when valid.
For a polished app, distinguish “invalid session” from “temporarily offline.” You may allow a limited offline mode after a previously validated session, but never perform protected writes until the server accepts the token.
Add the Bearer header to protected calls
Future<http.Response> getMyProfile(
http.Client client,
String baseUrl,
String token,
) {
return client.get(
Uri.parse(‘$baseUrl/api/userplus/me/’),
headers: {
‘Accept’: ‘application/json’,
‘Authorization’: ‘Bearer $token’,
},
);
}
Centralize this behavior in an authenticated client or repository. When any protected request returns 401, clear the session once and return the app to Login. Avoid triggering several logout navigations when concurrent requests fail together.
Logout
Future<void> logout(SessionStore store) async {
await store.clear();
// Also clear private profile, images, drafts, and other user-scoped caches.
}
User Plus’s JWT is a bearer credential with an expiry; this client-side flow does not assume a refresh token or server logout endpoint that the v5.0.4 response does not provide.
Test before release
- Valid email and password.
- Valid username and password.
- Wrong credentials and a 401 response.
- Timeout, airplane mode, invalid JSON, and server maintenance.
- Expired or corrupted stored token.
- Repeated taps on Sign in.
- Password-manager autofill and keyboard submission.
- Logout clearing all private cached data.
- Android and iOS release builds with clear-text HTTP disabled.
You now have the complete core: a usable Flutter form, a small User Plus client, secure JWT storage, session validation, authenticated headers, and logout. Add your state-management library around these same boundaries rather than embedding network and storage logic directly into every screen.







