Why Dart Base64URL Needs Normalize
JWT segments are Base64URL-encoded without padding — the spec strips trailing= characters. JavaScript's atoband Python'sbase64.urlsafe_b64decodecan be made to accept missing padding, but Dart's base64Url.decode is strict: input must be a multiple of 4 characters or you get FormatException: Invalid length. The fix is alwaysbase64Url.normalize(), which adds the correct number of =chars. This is the #1 gotcha for devs porting JWT code to Dart.
Method 1: Pure Dart Decode (No Dependencies)
Minimal decode
import 'dart:convert';
Map<String, dynamic> decodeJwtPayload(String token) {
final parts = token.split('.');
if (parts.length != 3) {
throw FormatException('Invalid JWT: expected 3 segments');
}
final normalized = base64Url.normalize(parts[1]);
final bytes = base64Url.decode(normalized);
final jsonStr = utf8.decode(bytes);
return jsonDecode(jsonStr) as Map<String, dynamic>;
}
void main() {
final payload = decodeJwtPayload(
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSJ9.sig'
);
print(payload['sub']); // 1234567890
print(payload['name']); // Ada
}Full decode with header + payload
class DecodedJwt {
final Map<String, dynamic> header;
final Map<String, dynamic> payload;
final String signature;
DecodedJwt(this.header, this.payload, this.signature);
}
DecodedJwt decodeJwt(String token) {
final parts = token.split('.');
if (parts.length != 3) throw FormatException('Invalid JWT');
Map<String, dynamic> seg(String s) =>
jsonDecode(utf8.decode(base64Url.decode(base64Url.normalize(s))))
as Map<String, dynamic>;
return DecodedJwt(seg(parts[0]), seg(parts[1]), parts[2]);
}
final jwt = decodeJwt(token);
print(jwt.header); // {alg: HS256, typ: JWT, kid: abc}
print(jwt.payload); // {sub: 123, exp: 1735689600}Method 2: dart_jsonwebtoken (Verification)
pubspec.yaml
dependencies:
dart_jsonwebtoken: ^2.14.0
flutter_secure_storage: ^9.0.0Verify HS256 shared secret
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
try {
final jwt = JWT.verify(token, SecretKey('your-secret'));
print(jwt.payload); // {sub: 123, exp: ...}
print(jwt.header); // {alg: HS256, typ: JWT}
} on JWTExpiredException {
// Prompt refresh or re-login
} on JWTInvalidException catch (ex) {
// Bad signature, bad algorithm, or malformed
print('Invalid: ${ex.message}');
} on JWTException catch (ex) {
print('JWT error: ${ex.message}');
}Verify RS256 with PEM public key
const pem = '''-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----''';
final jwt = JWT.verify(token, RSAPublicKey(pem));
print(jwt.payload);Sign (server-side Dart with shelf or dart_frog)
final jwt = JWT({
'sub': userId,
'role': 'admin',
'iss': 'myapp.com',
});
final token = jwt.sign(
SecretKey(Platform.environment['JWT_SECRET']!),
expiresIn: Duration(hours: 1),
);
// Return token in responseMethod 3: Flutter Example — AuthService
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class AuthService {
static const _storage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
Future<void> saveToken(String token) async {
await _storage.write(key: 'access_token', value: token);
}
Future<String?> getToken() => _storage.read(key: 'access_token');
Future<bool> isLoggedIn() async {
final token = await getToken();
if (token == null) return false;
try {
final payload = decodeJwtPayload(token);
final exp = payload['exp'] as int;
return DateTime.fromMillisecondsSinceEpoch(exp * 1000).isAfter(DateTime.now());
} catch (_) {
return false;
}
}
Future<String?> getUserEmail() async {
final token = await getToken();
if (token == null) return null;
try {
return decodeJwtPayload(token)['email'] as String?;
} catch (_) {
return null;
}
}
Future<void> logout() => _storage.delete(key: 'access_token');
}Method 4: Dio Interceptor for Auto-Refresh
import 'package:dio/dio.dart';
class AuthInterceptor extends Interceptor {
final AuthService auth;
final Dio dio;
AuthInterceptor(this.auth, this.dio);
@override
Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await auth.getToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
final newToken = await auth.refresh(); // call refresh endpoint
if (newToken != null) {
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
final response = await dio.fetch(err.requestOptions);
return handler.resolve(response);
}
}
handler.next(err);
}
}Common Dart JWT Pitfalls
- Skipping
base64Url.normalize()— Dart's decoder is strict about padding; always normalize first. - Using SharedPreferences for tokens — plaintext storage, visible to rooted devices. Use flutter_secure_storage.
- Treating
expas milliseconds — JWTexpis seconds since epoch; multiply by 1000 beforefromMillisecondsSinceEpoch. - Verifying on the client with a shared secret — attackers can decompile a Flutter APK/IPA and extract constants. Only verify server-side.
- No algorithm whitelist — dart_jsonwebtoken infers algorithm from the header; pass
checkHeaderType: trueand inspectjwt.header["alg"]if you need stricter control. - Blocking the UI thread on RS256 verify — on older Android devices, RSA verify can take 20-50ms. Wrap in
compute()for a background isolate.
Related Tools
- JWT Decoder Online — paste-and-inspect UI
- JWT Decoder in Swift — iOS counterpart
- JWT Decoder in Kotlin — Android native
- Verify JWT Signature — HS256/RS256 deep dive
- Base64 Encode in Dart — the encoding JWT is built on