Walkthrough: Basic Authentication
Sample: examples/basic_authentication · Needs: MySQL
This is the smallest complete auth flow: register a user, log in, call a protected route with a bearer token, and log out (revoking the token). It uses vania_auth for password hashing and JWT access tokens, backed by a MySQL database.
If you have not read the Authentication package page, skim it first — this walkthrough shows that stack assembled in a real app.
Run it
Start MySQL and create the database:
docker run -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secret \
-e MYSQL_DATABASE=basic_authentication mysql:8
Then:
cd examples/basic_authentication
dart pub get
dart run bin/server.dart
# Register — returns the user and a token
curl -X POST localhost:8000/api/register \
-H 'Content-Type: application/json' \
-d '{"name":"Ada","email":"[email protected]","password":"password123","password_confirmation":"password123"}'
# Call a protected route with the token
curl localhost:8000/api/me -H 'Authorization: Bearer <token>'
Boot: assembling the auth stack
The auth stack is registered in bin/server.dart, before the app initialises. This is the one place the pieces are named:
// bin/server.dart
void main(List<String> arguments) async {
registerMySqlDriver();
await AuthServiceProvider().register(
jwtSecretKey: env('APP_KEY'),
tokenStore: ModelPersonalAccessTokenStore(model: PersonalAccessToken()),
userProvider: ModelUserProvider(model: User()),
);
await Application().initialize(config: config);
}
Three decisions are being made here:
jwtSecretKey— the secret that signs and verifies tokens. It comes fromAPP_KEY. Change it and every existing token becomes invalid.tokenStore— where tokens are persisted.ModelPersonalAccessTokenStorekeeps them in thepersonal_access_tokenstable, which is what makes logout (revocation) possible.userProvider— how a user is loaded from a token.ModelUserProviderlooks users up via theUsermodel.
The models
Two models back the flow. The User hides its password from JSON output — a small but important detail so you never accidentally leak a hash:
// lib/app/models/user.dart
class User extends Model {
@override
List<String> get fillable => ['name', 'email', 'password'];
@override
List<String> get hidden => ['password']; // never serialised into responses
}
// lib/app/models/personal_access_token.dart
class PersonalAccessToken extends Model {
@override
String get tableName => 'personal_access_tokens';
@override
List<String> get fillable =>
['name', 'tokenable_id', 'token', 'expires_at', 'last_used_at', 'revoked_at'];
}
The migrations
The two tables the flow needs. Note the token table stores revoked_at — that column is how logout works.
// lib/database/migrations/create_users_table.dart
await create('users', (Schema schema) {
schema.id();
schema.string('name');
schema.string('email').unique();
schema.string('password');
schema.timeStamps();
}, true);
// lib/database/migrations/create_personal_access_tokens_table.dart
await create('personal_access_tokens', (Schema schema) {
schema.id();
schema.string('name');
schema.bigInt('tokenable_id');
schema.string('token').unique();
schema.timeStamp('expires_at').nullable();
schema.timeStamp('last_used_at').nullable();
schema.timeStamp('revoked_at').nullable();
schema.timeStamps();
}, true);
Register: hash, then issue a token
// lib/app/http/controllers/auth_controller.dart
Future<Response> register(Request req) async {
await req.validate({
'name': 'required|string|max_length:100',
'email': 'required|email|unique:users',
'password': 'required|min_length:8|confirmed',
});
final user = await User().query.create({
'name': req.input('name'),
'email': req.input('email'),
'password': Auth().hash.make(req.input('password')), // never store plain text
});
Auth().login(user);
final token = await Auth().createToken(expiresIn: Duration(days: 30));
return Response.json({'user': user, 'token': token['access_token']}, 201);
}
Two rules to internalise:
Auth().hash.make(...)hashes the password before it ever touches the database. You never store what the user typed.'password': 'required|min_length:8|confirmed'—confirmedrequires a matchingpassword_confirmationfield in the request. Validation runs before anything else, so a mismatch is rejected with a422.
Login: verify, then issue
Future<Response> login(Request req) async {
await req.validate({'email': 'required|email', 'password': 'required'});
final user = await User().query.where('email', '=', req.input('email')).first();
if (user == null || !Auth().hash.verify(req.input('password'), user['password'])) {
return Response.json({'message': 'Invalid credentials'}, 401);
}
Auth().login(user);
final token = await Auth().createToken(expiresIn: Duration(days: 30));
return Response.json({'user': user, 'token': token['access_token']});
}
Auth().hash.verify(plain, hash) compares the submitted password against the stored hash. The same 'Invalid credentials' message is returned whether the email is unknown or the password is wrong — so an attacker cannot tell which emails are registered.
Logout: revoke the token
Future<Response> logout(Request req) async {
final authorization = req.header('authorization');
if (authorization != null) {
final token = authorization.replaceFirst('Bearer ', '');
await Auth().revokeToken(token);
}
return Response.json({'message': 'Logged out'});
}
Because tokens are persisted, revoking one marks it invalid on the server. A JWT is otherwise valid until it expires — the token store is what gives you a real "log me out now" button.
Protecting routes
Public and protected routes live in the same file. The protected ones are wrapped in a group guarded by the Authenticate() middleware:
// lib/route/api_route.dart
// Public
Router.post('/register', authController.register);
Router.post('/login', authController.login);
// Protected — require a valid bearer token
Router.group(() {
Router.get('/me', authController.me);
Router.post('/logout', authController.logout);
}, middleware: [Authenticate()]);
Authenticate() reads the Authorization: Bearer … header, verifies the token against the store, loads the user, and makes it available as req.user inside the handler:
Future<Response> me(Request req) async => Response.json(req.user);
If the token is missing, invalid, expired, or revoked, the request never reaches the controller — the middleware rejects it with a 401.
Tests without a database
The test suite drives the real Auth service through register → login → verify → logout, but supplies in-memory implementations of UserProvider and PersonalAccessTokenStore. So the whole flow is tested with no MySQL running:
dart test
This is the same "swap the implementation behind an interface" idea from the Todos and DDD Wallet samples, applied to the auth stores.
What to take away
- Assemble the auth stack once in
bin/server.dart: secret, token store, user provider. - Hash on the way in, verify on the way through — never store or compare plain passwords.
- Persisted tokens are what make revocation (logout) real.
- Guard routes with
Authenticate(); read the current user viareq.user. hiddenon a model keeps secrets out of JSON responses.