Authentication¶
How players and admins get an account and prove who they are: local accounts, two-factor authentication, sessions, API tokens, and OAuth2 / OpenID Connect in both directions.
Local accounts¶
Registration¶
Registration is open by default and controlled by ctf.allow_registration in
Competition settings. Turning it off hides the form and
rejects the endpoint, so late sign-ups stop without touching anything else.
Whether an email address is required depends on whether email is configured:
| Email enabled | Address | Effect |
|---|---|---|
| Off | Optional | The account is usable immediately. |
| On | Required | A verification link is sent, and login is blocked until it is used. |
Registration is rate limited to 5 attempts per minute per IP address. That limit is
fixed and is not one of the Security settings. If
captcha is enabled, the registration form is challenged too. The
captcha is a self-hosted ALTCHA proof-of-work, signed with SECRET_KEY; there is no
third-party service to sign up for and nothing leaves your instance.
Turning email on mid-event
Accounts registered while email was off were created already verified, so they keep working. Only accounts created afterwards need to verify.
Email verification¶
The verification link is valid for 24 hours and works once. Players who let it lapse can request a new one from the login screen, rate limited to 3 requests per minute per IP address.
Requesting a new link does not cancel the old one
Each request mints an independent token. Every link sent in the last 24 hours stays usable until it expires or is clicked. That is harmless, since they all verify the same address, but it means resending is not a way to revoke a link that went astray.
Login is only gated on verification while email is enabled. Otherwise an unverified account could never be unlocked, so the check is skipped rather than locking people out.
Password reset¶
Forgot password mails a single-use link valid for 1 hour. The link is consumed the moment it is opened, so a second click fails even inside the hour. Requests are rate limited to 3 per minute per IP address, and the address is matched case-insensitively.
The form always reports success, whether or not the address matches an account, so it cannot be used to test which addresses are registered. As with verification, requesting a second link leaves the first one valid until it expires.
Without email configured there is no self-service reset. An admin can still generate a reset link for any user from Admin → Users; the link is shown ready to copy, and can be handed over out of band. That path does not need SMTP.
Login¶
Passwords are hashed with Argon2. A login attempt against an unknown username performs the same hashing work as a real one, so response timing does not reveal which usernames exist.
Login is rate limited per IP, and unlike registration this one is configurable under
Security settings, via rate_limit.login.max_requests and
rate_limit.login.window_seconds.
Every attempt is recorded in the event log, successes as user.login and
failures as user.login_failed with the reason:
| Reason | Meaning |
|---|---|
unknown_user |
No account with that username. |
bad_password |
Wrong password. |
bad_totp |
Password correct, TOTP code wrong. |
disabled |
Account deactivated by an admin. |
email_not_verified |
Credentials correct, verification link not used yet. |
A run of unknown_user failures from one address is the signature of username
enumeration; a run of bad_password against one account is a brute-force attempt.
Two-factor authentication¶
Any user can enrol a TOTP authenticator from their own profile settings. It is per-user and opt-in. There is no instance-wide setting that forces it on, so ask your admins to enable it rather than assuming.
Enrolment is the usual three steps: the platform shows a secret and QR code, the user scans it, and the setup is only committed once they enter a valid code. The pending secret is held for 10 minutes; an enrolment that is not confirmed in that window expires and leaves the account unchanged.
Once enabled, login asks for the 6-digit code after the password. Codes from the immediately preceding and following time step are accepted, so a device whose clock drifts by up to 30 seconds still works.
Disabling TOTP requires a current code, so someone who walks up to an unlocked browser cannot quietly remove it.
There are no recovery codes
A user who loses their authenticator cannot recover the account themselves. An admin has to issue a password-reset link from Admin → Users. Make sure at least one admin account is reachable by someone other than the person who owns it.
Sessions¶
Signing in sets a signed NexCTF cookie, valid for 24 hours and marked Secure
outside development. Session records live in PostgreSQL, not in the cache, so restarting
the cache container does not sign anyone out.
From their profile settings a user can see every active session, with its browser user agent, originating and most recent IP address, and last activity. Any session can be revoked individually, or all of them at once.
Admins see the same list for any account under Admin → Users → user, and can sign
one device out from there without touching the password. The session an admin is
currently using is marked as such, so they do not revoke their own by accident. Each
revocation is recorded in the event log as admin.user_session_revoked,
with the target account in the metadata.
Revoking a session is not the same as locking an account out
A revoked session only ends that one device. Someone who still knows the password signs straight back in. To actually stop an account, deactivate it: that bumps the session version and invalidates every session at once.
Two things invalidate sessions everywhere at once, by bumping the account's session version:
- changing the account password
- an admin deactivating the account
Rotating SECRET_KEY invalidates every session on the instance, since the cookie
signature no longer verifies.
API tokens¶
Tokens are the non-interactive way in, for scripts and integrations. A user creates them from their profile settings; each has a name and an optional expiry, and a user can hold several.
Send one as a bearer token:
| Property | Behaviour |
|---|---|
| Prefix | Every token starts with nexctf_, which makes them easy to catch in secret scanners. |
| Storage | Only a SHA-256 hash is stored. The value is shown once, at creation. |
| Expiry | Optional. Without one the token is valid until revoked. |
| Revocation | Immediate, from the same screen. |
| Permissions | Chosen per token, from the scopes below. A token never carries more than it was granted. |
Token scopes¶
A token is granted a set of verb:group scopes at creation, and every request it makes
is checked against them. The verb comes from the HTTP method: GET, HEAD and
OPTIONS need read, and POST, PUT, PATCH and DELETE need write. The group
comes from the endpoint.
| Group | Covers |
|---|---|
profile |
The owner's own profile and team membership. |
token |
Listing and revoking API tokens. |
challenge |
Challenges and their attachments, including answer submission. |
scoreboard |
The scoreboard. |
team |
Teams. |
notification |
Notifications and the event stream. |
content |
Pages and site info. |
plugin |
Endpoints a plugin mounts on the player side. |
Admins can additionally grant the administrative groups:
| Group | Covers |
|---|---|
admin.challenge |
Challenges, questions, solutions, hints, files, submissions, score adjustments. |
admin.user |
Users and custom fields. |
admin.team |
Teams. |
admin.scoreboard |
Scoreboard and statistics. |
admin.notification |
Notifications and email. |
admin.content |
Pages and links. |
admin.config |
Settings, the event log, the scheduler, feedback, OAuth providers and clients, plugins. |
admin.backup |
Creating, downloading, deleting and restoring database backups. |
admin.plugin |
Endpoints a plugin mounts on the admin side. |
Three rules are worth knowing before you build the list:
- Granting a write grants the matching read. Asking for
write:admin.challengestoresread:admin.challengealongside it, so you never have to tick both. - A player cannot grant an admin group. The scopes on offer are narrowed to the
owner's own role, and a request for one outside it is refused with
SCOPE-422. - A token needs at least one scope. Creating one with an empty list is refused.
Scopes are fixed for the life of the token. To widen or narrow one, revoke it and issue a replacement.
Every endpoint publishes the scope it needs
The OpenAPI schema carries an x-token-scope field on each operation, and the
rendered API docs repeat it in the description, so you can read off exactly what a
script needs rather than granting broadly and hoping.
A request that reaches an endpoint outside its scopes is refused with 403
SCOPE-403, naming the scope it was missing.
Endpoints a token cannot reach¶
Some endpoints refuse bearer authentication outright and answer 403
SCOPE-403-SESSION, whatever scopes the token carries:
- creating an API token
- revoking one of your own sessions
- unlinking an OAuth provider
- changing the password
- enrolling in or disabling TOTP
These are the operations that change how an account is secured, and requiring a browser session keeps a leaked token from being used to entrench itself.
The admin audit channel of the live event stream follows the same logic in a softer
form: an admin's token only receives it when it holds read:admin.config.
An admin token with admin scopes is an admin credential
Scopes narrow what a token can reach, not who it acts as. A token granted
write:admin.user can create administrators. Grant the narrowest set that does the
job, and treat a broadly scoped token like the password.
DEFAULT_ADMIN_TOKEN provisions a token at first start for automation, and it is
granted every scope available to an admin; see
Deployment. It is a bootstrap credential, not
one to keep in use: issue a narrowly scoped token for each job and revoke the default.
Tokens bypass 2FA by design
A token authenticates on its own, and TOTP is a login-flow check rather than a per-request one. That is what makes unattended scripts possible, and why an admin token deserves the same handling as a password.
Logging in with an external provider¶
NexCTF can delegate login to any OAuth2 / OpenID Connect provider that publishes a discovery document: Keycloak, Authentik, Auth0, GitLab, Google, and others. Providers are configured at Admin → OAuth providers, and each one you activate adds a button to the login page.
| Field | Notes |
|---|---|
| Slug | URL-safe identifier. It appears in the callback URL, so pick it before registering the client and do not change it afterwards. |
| Name | Label shown on the login button. |
| Client ID / Client secret | Issued by the provider. |
| Discovery URL | The provider's .well-known document. Endpoints are read from it, so you never enter them individually. |
| Scopes | Space-separated. Defaults to openid email profile. |
| Icon URL | Optional image for the login button. |
| Active | Inactive providers are hidden from the login page and their endpoints return 404. |
Register this redirect URI with the provider, substituting your BACKEND_HOST and the
slug you chose:
The redirect URI depends on DOMAIN
It is built from BACKEND_HOST, which is derived from DOMAIN unless you set it
explicitly. Leave both unset and the platform sends the provider a callback address
on http://localhost:8000, and the flow fails. If your public URL is not
https://<DOMAIN>, set BACKEND_HOST yourself. See
Deployment.
How accounts are matched¶
On a successful callback the provider's subject identifier is looked up:
- Known subject → sign in to the account it is linked to.
- Unknown subject, user already signed in → link the provider to the current account.
- Unknown subject, nobody signed in → create a new account.
New accounts take their username from the provider (preferred_username, then login,
then the subject id) and get a short random suffix if that name is already taken. The
provider's email is copied over and marked verified, since the provider vouched for it.
Accounts are never matched by email address
A matching email does not attach a provider identity to an existing local account. If it did, a provider that hands out unverified email addresses could be used to take over any account by claiming its address. Users who want both must sign in locally first, then link the provider from their profile settings.
The other side of that guarantee: if the provider's email is already claimed by another local account, the new account is created without an email address rather than colliding.
Users can see their linked providers, and unlink them, from their profile settings.
Unlinking can lock an account out
An account created through a provider has no password. Unlinking its only provider leaves no way in. Set a password first.
Using NexCTF as a provider¶
The other direction: NexCTF is itself an OAuth2 authorization server, so other applications can offer Sign in with NexCTF. Useful for a writeup site, a Discord bot, or anything else you run alongside the event that should share the same accounts.
Register applications at Admin → OAuth clients.
| Field | Notes |
|---|---|
| Name / Description | Shown to the user on the consent screen. |
| Redirect URIs | One per line. A callback that is not listed exactly is refused. |
| Allowed scopes | Space-separated subset of the scopes below. |
| Allowed roles | Space-separated, from admin and user. Leave empty to allow everyone. |
| Active | Inactive clients are refused. |
The client ID is generated for you; the client secret is shown once at creation and stored only as a hash. Losing it means rotating it.
What the server supports¶
| Discovery | /api/v1/oauth2/.well-known/oauth-authorization-server |
| Grant type | authorization_code |
| Response type | code |
| PKCE | Supported, S256 only |
| Client authentication | client_secret_post |
| Authorization code lifetime | 10 minutes, single use |
| Access token lifetime | 1 hour |
There are no refresh tokens: the client repeats the authorization flow when the access token expires.
Scopes¶
| Scope | Grants |
|---|---|
openid |
A stable user identifier. |
profile |
The username. |
email |
The email address. |
roles |
The platform role: admin or user. |
Request roles when the other application needs to mirror your admin list; without it a
client cannot tell an admin from a player.
Role restrictions are enforced on every authorization
Allowed roles is checked when the user reaches the consent screen, not only at registration. Demoting a user from admin to player immediately stops them authorizing an admin-only client, though an access token issued beforehand remains valid until it expires.
The issuer and endpoint URLs in the discovery document come from BACKEND_HOST. As with
external providers, an unset BACKEND_HOST advertises localhost to your clients.