Skip to content
v0.5 isn't released yet: these pages follow the main branch. To try it, install the release candidate, v0.5.0-rc.1.
Check your app's security

Check your app's security

security:check checks your app against security rules: its code (SQL built from request data, a redirect to a URL from the request, a secret written in a file), its dependencies (known vulnerabilities, with govulncheck), its routes (a form’s route without CSRF protection, an admin page anyone can open), its settings (session cookies sent over plain HTTP, mail in plain text) and, with --url, the running site (missing security headers, a .env served to anyone). Each finding says where it is (a line of your code, or a setting), why it matters and how to fix it. The report comes as text, JSON, SARIF (for GitHub’s code scanning) or Markdown.

Before you start

An app made with anetos new, with its settings (.env): the check boots the app, as other commands do. The settings’ rules apply where the app is deployed, so run the check with the production settings too (step 4).

Steps

1. Run the check

In the project:

go tool anetos security:check
Security check of blog (APP_ENV=development, level basic).
The settings' rules apply to production and staging: run it with those settings too (on the server, or APP_ENV=production with them).

problem  route.csrf  POST /posts/import  routes/web.go:31
         it changes data with the session's user and has no CSRF protection: another site's page can send it as the logged-in user; add web.CSRF() to its group, after the session middleware
         A01:2025 Broken Access Control · https://docs.anetos.dev/guides/security-check/#routecsrf

problem  source.sql  app/handlers.Posts.Search  app/handlers/posts.go:48
         Raw gets SQL built from request data: a client can change the query (SQL injection); pass values as arguments (?), and choose column names from a list
         A05:2025 Injection · https://docs.anetos.dev/guides/security-check/#sourcesql

2 problems, 0 warnings, 0 notes.
None of these rules finding anything doesn't make the app secure: who may read or change whose records, business logic and the code itself need tests and review.

The tool builds the app, checks the code and runs govulncheck (built in; it needs the network for vuln.go.dev, and --no-vuln skips it), then runs the app’s security:check command, as doctor does, which adds its own findings: one report. --out and --ignore name files relative to the folder you’re in. A problem is almost always a hole, and the command exits 1; a warning is often a mistake; a note is worth knowing. --strict fails on warnings too.

2. Fix, or say why it’s fine

Each finding names its rule and its fix (the rules below). A route that is open to everyone on purpose says so in the code, with a reason, and the rules about logins (route.login, route.api-auth) leave it out:

// illustrative
r.Post("/webhooks/stripe", h.Stripe).Public("Stripe signs its webhooks: the handler checks the signature")

Any other finding you decide to keep goes in security-ignore.yaml, at the project’s root, with the reason (required) and, if you like, a date after which it’s reported again:

- rule: route.csrf
  where: POST /import
  reason: the importer's form posts from our own extension, with a token of its own
  expires: 2027-03-31
- rule: setting.log-level   # every place of the rule
  reason: we're looking into a bug this week
  expires: 2026-11-07

where is the place as the report shows it: a route (POST /import), a setting, a function with its package’s path in the module (app/handlers.Posts.Search; a function literal counts as the function it’s in), a file (for source.secret) or a vulnerability (GO-2026-6612 golang.org/x/net/http2). An entry without where ignores every place of its rule. It expires at the end of its day in the app’s time zone (APP_TIMEZONE); a key the check doesn’t know (wher:) is an error, since a typo would ignore more, for longer. The report lists what it ignored. An entry that expired while its finding is still there is a warning, and one that matches nothing (fixed, or expired and fixed) a note. Commit the file: it is where a reviewer sees what was let through, and why.

3. Look further

The basic level (the default) has the rules that find mistakes with few false alarms. --level=advanced adds those that find what needs a look and may be on purpose: an API route that changes data without a login, a handler that loads a record by ID without an access check, SQL built from strings that aren’t constants, debug logs in production.

go tool anetos security:check --level=advanced

4. Check the production settings

Your machine has development settings, where most of the settings' rules don’t apply. Run the binary’s command where the production settings are, as you run doctor. It checks the settings and the routes; the code and the dependencies are the tool’s, in the project:

./blog security:check

5. Run it in CI

The SARIF report puts each finding on its line in a pull request, with GitHub’s code scanning:

# illustrative: a GitHub Actions job
security:
  runs-on: ubuntu-latest
  permissions:
    contents: read
    security-events: write # to upload the report
  steps:
    - uses: actions/checkout@v7
    - uses: actions/setup-go@v7
      with:
        go-version: stable # the Go you build with: govulncheck checks its standard library
    # The app boots with development settings: the route rules apply.
    - run: cp .env.example .env && go tool anetos key:generate
    - run: go tool anetos security:check --format=sarif --out=security.sarif
    - uses: github/codeql-action/upload-sarif@v4
      if: always() # the report too when the check fails
      with:
        sarif_file: security.sarif

The job fails on a problem, and the findings show in the pull request. Files in the report are relative to the repository’s root, also when go.mod is in a folder of it. --format=json gives one object to read in a script, and --format=markdown a table for an issue or a review. --out=FILE writes the report to a file (-: the standard output).

go tool anetos check, which you and coding agents run before calling a change done, ends with the basic rules (without govulncheck): a problem fails it. Coding agents also have the MCP server’s security_check tool and the anetos-security skill, which says how to fix each finding (Build with a coding agent).

6. Check the running site

--url adds checks of the deployed site, from outside: the headers and cookies of its page, its certificate, the redirect from http://, files it mustn’t serve (/.env, /.git/, /debug/pprof/) and stack traces in its error pages; at the advanced level, CORS, TRACE, version headers and a certificate about to expire. It sends 24 requests at most (usually about 15): GET, a TLS handshake offering only old versions and, at the advanced level, one TRACE, with no attack in them and a User-Agent that names it, within a minute. It reads logged-out pages only, and refuses a URL with a user or password. It follows redirects on the same host (http:// to https://, / to /en/) and scans from the page it lands on; a redirect to another host (the apex to www.) stops it, saying which address to scan.

go tool anetos security:check --url=https://blog.example.com

It scans only the project’s own site: APP_URL’s host (from the environment, else .env) or localhost; --allow-host=HOST adds one (a staging host, say). Scan only sites that are yours. .env on your machine has APP_URL=http://localhost:8080: set the production APP_URL in the environment for the command, or add the host with --allow-host. In a project, the other checks run too; --live-only, or a folder outside a project, runs the site’s checks alone, without .env or a build, as a CI job after a deploy:

# illustrative: a step after the deploy
- run: go tool anetos security:check --url=https://blog.example.com --live-only --strict
  env:
    APP_URL: https://blog.example.com

The rules

route.csrf

A route that changes data (POST, PUT, PATCH, DELETE, or any method) behind the session middleware, without web.CSRF: another site’s page can send it with the visitor’s session. Fix: add web.CSRF() to the route’s group, after sessions.Middleware, as anetos new’s pages group has it. Problem; A01:2025 Broken Access Control.

route.login

A route under /admin (or named admin.…, or on an admin. host) without a login: auth’s Require, rbac.Require, rbac.RequireIn or auth.RequireAbilities. Fix: put it in a group with them; or .Public("why") if it’s open on purpose. Problem; A01:2025.

route.debug

A route that serves Go’s debug endpoints (/debug/pprof/, /debug/vars) without a login, for GET or any method: /debug/pprof/… itself, or a mount such as Handle("", "/debug/{path...}", web.WrapHandler(http.DefaultServeMux)). They show the app’s memory and settings, and a profile slows it down. Public doesn’t excuse them. Fix: leave them out of production builds, or behind a login and rbac.Require. Problem; A02:2025 Security Misconfiguration.

route.rate-limit

A POST route that creates accounts or sends email (/register, /forgot-password, /email/verification-notification) without ratelimit.Middleware: a script can fill the database or send mail in your name. Fix: add a limit, as make:auth’s routes have. Logging in isn’t one of them: auth limits login attempts itself (AUTH_THROTTLE). Warning; A07:2025 Authentication Failures.

route.api-auth

Advanced. An API route (under /api/, or behind web.JSONErrors) that changes data without a login, other than make:auth’s routes for guests (register, log in, reset a password). Fix: put it behind auth’s Require, or .Public("why"). Warning; A01:2025.

setting.debug

APP_DEBUG=true in staging (production refuses it): error pages show stack traces and settings. Warning; A02:2025.

setting.app-key

No APP_KEY where the app is deployed. Warning; A04:2025 Cryptographic Failures.

setting.app-url

An http:// APP_URL where the app is deployed: links in emails and OAuth callbacks go over plain HTTP. Problem in production, warning in staging; A04:2025.

setting.session-secure

SESSION_SECURE=false where the app is deployed: the session cookie goes over plain HTTP too. Problem; A07:2025.

setting.session-same-site

SESSION_SAME_SITE=none: other sites’ requests carry the session cookie. Warning; A01:2025.

setting.session-domain

SESSION_DOMAIN set: every subdomain gets the session cookie. Note; A07:2025.

setting.trusted-proxies

HTTP_TRUSTED_PROXIES trusting everyone, or a very wide range: clients choose the IP address the app sees. Problem or warning; A02:2025.

setting.cors

HTTP_CORS_ORIGINS="*": scripts on any site may call the app. Warning; A02:2025.

setting.body-limit

HTTP_MAX_BODY=0 where the app is deployed: one client can fill the memory with a request’s body. Warning; A02:2025.

setting.request-timeout

HTTP_REQUEST_TIMEOUT=0 where the app is deployed: slow requests hold connections and memory for as long as they like. Warning; A02:2025.

setting.header-timeout

HTTP_READ_HEADER_TIMEOUT=0 where the app is deployed: a client that sends its headers slowly holds a connection (Slowloris). Warning; A02:2025.

setting.mail-tls

MAIL_SMTP_URL with tls=none for a server off the machine: emails, with their reset and login links, cross the network in plain text. Warning; A04:2025.

setting.db-tls

A database off the machine without TLS, or without checking the server’s certificate. Warning; A04:2025.

setting.db-log-queries

DB_LOG_QUERIES=true where the app is deployed: the log gets every query with its values. Warning; A09:2025 Security Logging and Alerting Failures.

setting.log-level

Advanced. LOG_LEVEL=debug where the app is deployed. Warning; A09:2025.

source.sql

SQL built from request data (with +, fmt.Sprintf, a strings.Builder) given to db.Raw, db.Exec, db.SQL, db.OrderRaw, WhereRaw, Join, GroupBy, db.Select, SetRaw, a migration’s Exec or the methods of database/sql’s DB, Tx and Conn: a client can change the query. Fix: pass values as arguments (?), and choose a column or a sort order from a list: a value checked in the same function against constants (a switch whose default replaces it or returns, if !slices.Contains(list, v), if !allowed[v], if _, ok := allowed[v]; !ok, v != "a" && v != "b") isn’t the client’s anymore. Problem; A05:2025 Injection. At the advanced level, SQL built from strings that aren’t constants, wherever they come from, is a warning: look at where they come from.

The rules follow request data (c.Param, c.Query, c.Header, http.Request’s form, URL, headers and body, and a typed handler’s input) through one function, not into the functions it calls: a helper that builds SQL from its parameters shows at the advanced level only.

source.xss

Request data given to templ.Raw, c.HTML, or made template.HTML (or JS, URL, CSS…), which aren’t escaped: anyone can put a script in the page. Fix: let templ or html/template escape it; sanitize HTML you must keep. Problem; A05:2025. In .templ files, the finding is at the .templ line; the rule sees request data a view reads itself, not what a handler passes to a component’s parameters, nor text written to an http.ResponseWriter.

source.redirect

A redirect (c.Redirect, web.Redirect, http.Redirect) to a URL from the request: a link to your site can send visitors anywhere. Not when the URL starts with a constant that keeps it on a known site ("/posts/"+id, "https://example.com/"+path), or when the function checks it first: strings.HasPrefix(next, "//") (with the leading /), or url.Parse and a look at the result’s Host, Scheme or IsAbs. Fix: allow your own paths only (a leading /, not //), or redirect to a route by name. Problem; A01:2025.

source.exec

exec.Command with request data: as the program, or in a shell’s command line (sh -c), a problem (a client can run any command); as another argument, a warning (a value starting with - becomes an option). Fix: choose the program from a list, pass the value as one argument, after --. A05:2025.

source.tls

tls.Config{InsecureSkipVerify: true}: TLS connections accept any certificate, so anyone on the network can read and change them. Fix: give the server’s authority (RootCAs). Problem; A04:2025.

source.rand

math/rand (or math/rand/v2) for a value whose name says it’s a secret (token, password, salt, otp, sessionID, resetCode…; not sessionCount), returned by a function named so (NewToken), or math/rand.Read anywhere: its numbers can be predicted. Fix: crypto/rand (rand.Text() for a token). Warning; A04:2025.

source.secret

A secret written in the project’s files (Go, templ, YAML, JSON, TOML, scripts; not .env and the other files of settings, nor tests): a key recognized by its form (AWS, Stripe, GitHub, Slack, Google, Anthropic, OpenAI, SendGrid, a private key) is a problem; a value that looks random given to a name like password, secret, token or api_key (not passwordHash), a warning: in code, a string literal; in settings files, any value. Files that say they’re generated aren’t searched. The report shows the secret’s start only. Fix: revoke it, and read it from a setting. A04:2025.

source.path

Advanced. A file path from the request given to os.Open, os.ReadFile, os.WriteFile, os.Remove… or http.ServeFile: ../ reaches any file the app can read. Fix: keep the name only (filepath.Base) in a known folder, or open it with os.Root. Warning; A01:2025.

source.ssrf

Advanced. An HTTP request (http.Get, http.NewRequest, a client’s Get…) to a URL from the request: a client can make the app call internal services. Fix: allow only the hosts you expect. Warning; A01:2025.

source.mass-assign

Advanced. web.H with a handler whose input is a model: a client can set any of its fields (ID, owner, role). Fix: bind into a struct with the fields a form may change, and copy them. Warning; A08:2025 Software or Data Integrity Failures.

source.owner

Advanced. A handler that loads a record by an ID from the request (db.Find, a query’s Find without a Where) and checks nobody’s access: no rbac call, no auth.Authorize… or Allows…, nothing named like a check (CanEdit, authorizeOwner, postPolicy), and no comparison of an owner field (note.UserID != user.ID). Getting the current user isn’t a check. Any logged-in user can open any record by changing the ID. Fine when every user may see every record, as make:crud’s handlers assume. Fix: check access, or scope the query to the user. Warning; A01:2025.

dependency.vuln

A known vulnerability (govulncheck, from vuln.go.dev) in a module or in Go’s standard library: in a function the app calls, a problem, at the line of your code nearest the vulnerable call; in a package it imports without calling the vulnerable code, a note, at the module’s line in go.mod (the standard library’s: the go or toolchain line). Fix: update the module (go get module@version, the report says which), or build with the Go release that fixed it. When govulncheck can’t reach the database, or doesn’t finish in 5 minutes, a note says so; when it fails otherwise, a warning. A03:2025 Software Supply Chain Failures.

live.headers

The site’s page (after its redirects on the same host; a redirect’s own headers aren’t judged) without Strict-Transport-Security (on https), X-Content-Type-Options: nosniff or protection from framing (X-Frame-Options, or a CSP’s frame-ancestors), warnings; without Referrer-Policy or a Content-Security-Policy, notes. web.SecureHeaders sends all but the CSP: a header missing usually means a proxy or another server answers. Fix: send them (Secure your app for a CSP). A02:2025.

live.cookie

A cookie without Secure on an https site (a problem for a session or login cookie, else a warning), a session cookie without HttpOnly (warning), SameSite=None without Secure (problem; not on localhost). A session cookie is told by its name (session, sid, auth, token, remember…; not XSRF-TOKEN or csrftoken, which scripts read on purpose). Fix: SESSION_SECURE=true, and the flags on your own cookies. A07:2025.

live.tls

A certificate that doesn’t verify, or a site that offers only TLS older than 1.2 (the scan stops there), or one that completes a handshake with TLS 1.0 or 1.1 besides newer versions. Problem; A04:2025.

live.https

The site served over plain http://, or its http:// address serving pages instead of redirecting to https:// (a redirect elsewhere is a warning). Localhost is left alone. Problem; A04:2025.

live.exposed

The site serves /.env, /.env.production, /.git/config, /.git/HEAD, /debug/pprof/ or /debug/vars, recognized by their content (a page that answers every path isn’t a finding). Fix: stop serving them, block dotfiles and /debug/ at the proxy, and change the secrets they held. Problem; A02:2025.

live.debug

An error page with a Go stack trace: where is 404 page (a path that doesn’t exist) or malformed path. Fix: turn APP_DEBUG off where others reach the app. Problem; A02:2025.

live.unchecked

In a project, --url’s site didn’t answer, or sent its page to another host: the rest of the report stands, and this warning says the site wasn’t checked. (With --live-only, the command fails instead.) A02:2025.

live.cors

Advanced. The site echoes a foreign Origin in Access-Control-Allow-Origin: with Access-Control-Allow-Credentials: true, a problem (any site can act as the logged-in user); without, a warning. Fix: HTTP_CORS_ORIGINS with your own origins. A02:2025.

live.trace

Advanced. The site answers TRACE, echoing the request’s headers. Fix: turn it off at the proxy. Warning; A02:2025.

live.version

Advanced. Server, X-Powered-By or X-AspNet-Version with a version (nginx/1.18.0). Fix: hide it at the proxy. Note; A02:2025.

live.cert-expiry

Advanced. The certificate expires within 30 days. Fix: renew it, and make renewing automatic. Warning; A04:2025.

Other findings

  • check.<name>: a finding of a doctor check marked Security: true (an app’s or a plugin’s), with its severity; A02:2025 unless it says.
  • check.panic: a check or a rule panicked, so it didn’t check anything. Problem.
  • ignore.expired, ignore.unused: an entry of the ignore file that expired while its finding is there (warning), or that matches nothing (note).

Add your own rules

An app or a plugin adds rules of its own. A rule about routes sees each route with its middleware, the global ones first:

// illustrative
app.AddSecurityRule(web.RouteRule(anetos.SecurityRule{ID: "billing.webhook-signature",
	Summary: "Payment webhooks without the signature check", OWASP: anetos.OWASPAccessControl},
	func(rt web.RouteInfo) []anetos.SecurityFinding {
		if strings.HasPrefix(rt.Pattern, "/webhooks/") && !rt.Uses("billing.VerifySignature") {
			return []anetos.SecurityFinding{{Severity: anetos.Problem,
				Message: "a webhook without the signature check: anyone can post it; add billing.VerifySignature"}}
		}
		return nil
	}))

Other rules implement Run(ctx, app) themselves (anetos.SecurityRule), and a doctor check marked Security: true has its findings in the report too (as check.<name>). A rule’s Level is anetos.SecurityBasic (the default) or anetos.SecurityAdvanced. Name your rules after your app or plugin (billing.…): route., setting., source., dependency., live., check. and ignore. are the framework’s. See the CLI reference.

How it works

The tool builds the app, loads the project’s packages (not their tests) with their types, and runs its rules about the code, as an analyzer on each function; it searches the project’s files for secrets, and runs govulncheck. It writes what it found to a file, which the app’s security:check reads (--findings). The app’s command boots the app, as other commands do, then runs the doctor’s checks and keeps their findings about security (those with a rule), and runs the security rules: web’s about routes read route:list’s information, each middleware by name (Routing), and each route’s file and line, which the router records when the route is registered: the line of your code that registered it, or that called the module that did (the admin’s Mount). The report sorts the findings by severity, applies security-ignore.yaml, and gives each its OWASP Top 10:2025 category.

A clean report means none of these rules found anything, not that the app is secure: the rules follow request data within a function, not across functions or through the database, and can’t know who may read or change whose records; business logic and the code itself need tests and review.

Common problems

ProblemCauseFix
Nothing is found about the settingsAPP_ENV isn’t production or staging, where those rules applyRun ./<app> security:check with the production settings
route.csrf on a webhookThe route is in a group with the session middlewareMove it to a group of its own, without sessions (and check the sender’s signature)
A finding at a line in the module cacheA plugin registered the route from code your app doesn’t call (its own init, say)Look at the plugin’s settings for the fix; report it to its authors, or ignore it with a reason
the project doesn't buildThe code rules need the project’s packages to type-checkFix the build (go build ./...)
the known vulnerabilities weren't checkedgovulncheck couldn’t reach vuln.go.devRun it where it has the network; --no-vuln to skip it
A finding on code that is safeThe rule can’t see the check: it’s in another function, or not one of the forms it knowsCheck in the same function (a switch or slices.Contains against constants, a map of allowed values), or ignore it in security-ignore.yaml with the reason
not a host this project serves--url’s host isn’t APP_URL’s (on your machine, .env’s is localhost’s)Set APP_URL for the command (in CI, in the step’s environment), or add the host with --allow-host if it’s yours
redirects to www.…: scan that addressThe address you gave sends its pages to another hostScan that address, with --allow-host for its host
there's no .envThe tool needs the app’s settings to boot itcp .env.example .env && go tool anetos key:generate, or set APP_ENV and APP_KEY
field wher not foundA key of security-ignore.yaml misspelledUse rule, where, reason and expires
has no reasonAn entry of security-ignore.yaml without reason:Say why the finding is safe to leave

Next steps