Skip to content

Secure the studio

You will take a studio from a development setup to one you can deploy: strong credentials from the environment, JWT auth that works across isolates, HTTPS cookies behind a proxy, and an extra network gate. The result is an admin that only your team can reach and sign in to.

Make sure neither of these reaches production:

GisilaStudio(db: db, allowUnauthenticated: true); // no login at all
GisilaStudio(db: db, allowInsecure: true); // accepts weak credentials

and that GISILA_ALLOW_INSECURE=1 is not set in the production environment.

2. Use strong credentials from the environment

Section titled “2. Use strong credentials from the environment”
Terminal window
STUDIO_USERNAME=ops-admin
STUDIO_PASSWORD="$(openssl rand -base64 24)" # 12+ characters, not the username
STUDIO_SECRET="$(openssl rand -base64 48)" # 32+ characters

Store them in your secret manager or deployment platform, not in the repository. With all three set, GisilaStudio(db: db) builds a JwtStudioAuth.

If the credentials are too weak, the studio does not start serving: it returns 503 and logs, for example, gisila_studio: "Shop Admin" is disabled: its login is too weak: the password is shorter than 12 characters ....

SessionStudioAuth keeps sessions in one isolate’s memory. If you start the server with shared: true on several isolates, or run more than one instance, sign-in appears to work and then fails at random. JwtStudioAuth validates tokens with the shared secret instead.

Set the token lifetime explicitly; logout does not revoke a JWT, so a short lifetime limits a leaked token:

import 'dart:io';
import 'package:gisila_studio/gisila_studio.dart';
final env = Platform.environment;
final studio = GisilaStudio(
db: db,
title: 'Shop Admin',
auth: JwtStudioAuth(
username: env['STUDIO_USERNAME']!,
password: env['STUDIO_PASSWORD']!,
secret: env['STUDIO_SECRET']!,
expiry: const Duration(hours: 2),
),
);

The cookie gets the Secure flag when the request is HTTPS, or when a proxy sends X-Forwarded-Proto: https. Terminate TLS at your proxy and make sure it sets that header (most do by default). Without HTTPS, credentials and the session cookie travel in clear text.

The login is the only gate inside the studio, and sign-in throttling is per client address. Add a network-level restriction as well, for example an IP allowlist in front of the mount:

import 'dart:io';
import 'package:shelf/shelf.dart';
Middleware allowOnly(Set<String> addresses) => (inner) => (Request req) {
final info = req.context['shelf.io.connection_info'];
final ip = info is HttpConnectionInfo ? info.remoteAddress.address : null;
if (ip == null || !addresses.contains(ip)) return Response.forbidden('Forbidden');
return inner(req);
};
router.mount(
'/admin',
const Pipeline()
.addMiddleware(allowOnly({'10.0.0.5', '10.0.0.6'}))
.addHandler(studio.handler(prefix: '/admin')),
);

Behind a reverse proxy the TCP peer is the proxy, so do this filtering at the proxy instead (or in a VPN / private network). The same caveat applies to sign-in throttling: behind a proxy all clients share one counter, and ten failed attempts lock everyone out for 15 minutes.

For each table with password or token columns:

studio.register<User>(
UserTable.metadata,
onSave: {'password': (v) => hashPassword(v as String)},
);

or make the column read-only. register() warns at startup about editable password columns with no hook. Check passwordFields covers every secret column (the default only matches names containing password, passwd or secret; an api_token column needs adding).

7. Use a least-privilege database role (optional)

Section titled “7. Use a least-privilege database role (optional)”

The studio runs SQL with whatever role the Database connects as, including DELETE. Give it a separate gisila_orm connection whose role can only touch the registered tables.

/admin/login/
# Unauthenticated requests are redirected to the login page
curl -si https://admin.example.com/admin/ | grep -i '^location'
# A cookie set over HTTPS carries Secure
curl -si https://admin.example.com/admin/login/ \
-d 'username=ops-admin' --data-urlencode "password=$STUDIO_PASSWORD" | grep -i set-cookie
# set-cookie: _studio_jwt=...; Path=/admin; HttpOnly; SameSite=Lax; Secure
# Cross-site POSTs without the CSRF token are refused
curl -si -X POST https://admin.example.com/admin/product/1/delete/ \
-H "cookie: _studio_jwt=$TOKEN" | head -1
# HTTP/1.1 403 Forbidden