@@ -239,15 +239,16 @@ public Project createProject(Long workspaceId, CreateProjectRequest request) thr
239239 newProject .setAiConnectionBinding (aiBinding );
240240 }
241241
242- // generate internal auth token for the project
242+ // Generate a URL-safe auth token for webhook authentication, encrypted at rest.
243+ // The plaintext token uses URL-safe Base64 (no '/', '+', '=') so it can be
244+ // safely embedded in webhook URL paths. It's stored AES-encrypted in the DB
245+ // and decrypted when building webhook URLs or validating incoming webhooks.
243246 try {
244247 byte [] random = new byte [32 ];
245248 new SecureRandom ().nextBytes (random );
246249 String plainToken = Base64 .getUrlEncoder ().withoutPadding ().encodeToString (random );
247250 String encrypted = tokenEncryptionService .encrypt (plainToken );
248251 newProject .setAuthToken (encrypted );
249- // Note: plainToken is not returned in API responses; store encrypted token
250- // only.
251252 } catch (GeneralSecurityException e ) {
252253 throw new SecurityException ("Failed to generate project auth token" );
253254 }
@@ -905,18 +906,41 @@ public WebhookSetupResult setupWebhooks(Long workspaceId, Long projectId) {
905906 return new WebhookSetupResult (false , null , null , "No VCS connection found for this project" );
906907 }
907908
908- // Generate webhook URL
909- String webhookUrl = generateWebhookUrl (binding .getProvider (), project );
909+ // Ensure auth token exists and its plaintext form is URL-safe.
910+ // The token is stored AES-encrypted in the DB. We decrypt to verify that
911+ // the underlying plaintext is URL-safe (no '/', '+', '='). Older tokens
912+ // may have been generated without URL-safe encoding.
913+ try {
914+ boolean needsRegeneration = false ;
915+ if (project .getAuthToken () == null || project .getAuthToken ().isBlank ()) {
916+ needsRegeneration = true ;
917+ } else {
918+ try {
919+ String decrypted = tokenEncryptionService .decrypt (project .getAuthToken ());
920+ if (!isUrlSafeToken (decrypted )) {
921+ needsRegeneration = true ;
922+ }
923+ } catch (Exception e ) {
924+ // Token can't be decrypted (corrupt or old format) — regenerate
925+ needsRegeneration = true ;
926+ }
927+ }
910928
911- // Ensure auth token exists
912- if (project .getAuthToken () == null || project .getAuthToken ().isBlank ()) {
913- byte [] randomBytes = new byte [32 ];
914- new SecureRandom ().nextBytes (randomBytes );
915- String authToken = Base64 .getUrlEncoder ().withoutPadding ().encodeToString (randomBytes );
916- project .setAuthToken (authToken );
917- projectRepository .save (project );
929+ if (needsRegeneration ) {
930+ byte [] randomBytes = new byte [32 ];
931+ new SecureRandom ().nextBytes (randomBytes );
932+ String plainToken = Base64 .getUrlEncoder ().withoutPadding ().encodeToString (randomBytes );
933+ project .setAuthToken (tokenEncryptionService .encrypt (plainToken ));
934+ projectRepository .save (project );
935+ log .info ("Generated new encrypted URL-safe auth token for project {}" , projectId );
936+ }
937+ } catch (GeneralSecurityException e ) {
938+ return new WebhookSetupResult (false , null , null , "Failed to process auth token: " + e .getMessage ());
918939 }
919940
941+ // Generate webhook URL AFTER token check so it reflects the correct token
942+ String webhookUrl = generateWebhookUrl (binding .getProvider (), project );
943+
920944 try {
921945 // Get VCS client and setup webhook
922946 org .rostilos .codecrow .vcsclient .VcsClient client = vcsClientProvider .getClient (connection );
@@ -990,7 +1014,24 @@ private String generateWebhookUrl(EVcsProvider provider, Project project) {
9901014 String base = (urls .webhookBaseUrl () != null && !urls .webhookBaseUrl ().isBlank ())
9911015 ? urls .webhookBaseUrl ()
9921016 : urls .baseUrl ();
993- return base + "/api/webhooks/" + provider .getId () + "/" + project .getAuthToken ();
1017+ // Decrypt the stored token to get the URL-safe plaintext for the webhook path
1018+ String plainToken ;
1019+ try {
1020+ plainToken = tokenEncryptionService .decrypt (project .getAuthToken ());
1021+ } catch (GeneralSecurityException e ) {
1022+ log .error ("Failed to decrypt auth token for project {}" , project .getId (), e );
1023+ throw new IllegalStateException ("Cannot generate webhook URL: auth token decryption failed" );
1024+ }
1025+ return base + "/api/webhooks/" + provider .getId () + "/" + plainToken ;
1026+ }
1027+
1028+ /**
1029+ * Check if a token is safe for use in URL path segments.
1030+ * Standard Base64 contains '/', '+', '=' which break URL paths.
1031+ * URL-safe Base64 without padding only uses [A-Za-z0-9_-].
1032+ */
1033+ private boolean isUrlSafeToken (String token ) {
1034+ return token .indexOf ('/' ) < 0 && token .indexOf ('+' ) < 0 && token .indexOf ('=' ) < 0 ;
9941035 }
9951036
9961037 private List <String > getWebhookEvents (EVcsProvider provider ) {
0 commit comments